File
Blob: src/client/pages/files.tsx
| 1 | import { type FormEvent, useMemo, useState } from "react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | import { ChevronRight, FileIcon, FilesIcon, Folder, FolderOpen, FolderPlus, Home, Pencil, Trash2 } from "lucide-react"; |
| 4 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 5 | |
| 6 | import { $api } from "@/client/lib/api"; |
| 7 | import type { FileNode } from "@/client/lib/types"; |
| 8 | import { Button } from "@/client/components/ui/button"; |
| 9 | import { ConfirmDialog } from "@/client/components/ui/confirm-dialog"; |
| 10 | import { Dialog } from "@/client/components/ui/dialog"; |
| 11 | import { EmptyState } from "@/client/components/empty-state"; |
| 12 | import { ErrorBanner } from "@/client/components/error-banner"; |
| 13 | import { Input } from "@/client/components/ui/input"; |
| 14 | import { Label } from "@/client/components/ui/label"; |
| 15 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 16 | import { Skeleton } from "@/client/components/loading-panel"; |
| 17 | import { formatBytes, formatDate } from "@/client/lib/format"; |
| 18 | import { paths } from "@/client/lib/paths"; |
| 19 | import { toast } from "@/client/lib/toast"; |
| 20 | |
| 21 | function normalizeDirPath(input: string): string { |
| 22 | let value = input.trim(); |
| 23 | if (!value) value = "/files/"; |
| 24 | if (!value.startsWith("/files")) value = "/files" + (value.startsWith("/") ? value : `/${value}`); |
| 25 | if (!value.endsWith("/")) value += "/"; |
| 26 | return value; |
| 27 | } |
| 28 | |
| 29 | function parentOf(dir: string): string | null { |
| 30 | if (dir === "/files/" || dir === "/files") return null; |
| 31 | const trimmed = dir.endsWith("/") ? dir.slice(0, -1) : dir; |
| 32 | const idx = trimmed.lastIndexOf("/"); |
| 33 | return idx <= "/files".length - 1 ? "/files/" : `${trimmed.slice(0, idx)}/`; |
| 34 | } |
| 35 | |
| 36 | function buildBreadcrumbs(dir: string): { label: string; path: string }[] { |
| 37 | const segments = dir.replace(/^\/+/, "").replace(/\/+$/, "").split("/"); |
| 38 | const crumbs: { label: string; path: string }[] = []; |
| 39 | let acc = ""; |
| 40 | for (const segment of segments) { |
| 41 | acc += `/${segment}`; |
| 42 | crumbs.push({ label: segment, path: `${acc}/` }); |
| 43 | } |
| 44 | return crumbs; |
| 45 | } |
| 46 | |
| 47 | export function FilesPage() { |
| 48 | const search = useSearch({ from: paths.files }); |
| 49 | const navigate = useNavigate(); |
| 50 | const dir = normalizeDirPath(search.path); |
| 51 | |
| 52 | const listing = $api.useQuery("get", "/api/v1/files/list", { params: { query: { path: dir } } }); |
| 53 | const [mkdirOpen, setMkdirOpen] = useState(false); |
| 54 | const [renameTarget, setRenameTarget] = useState<FileNode | null>(null); |
| 55 | |
| 56 | const breadcrumbs = useMemo(() => buildBreadcrumbs(dir), [dir]); |
| 57 | const parent = parentOf(dir); |
| 58 | |
| 59 | const goTo = (path: string) => navigate({ to: paths.files, search: { path } }); |
| 60 | |
| 61 | return ( |
| 62 | <div className="space-y-6"> |
| 63 | <PageHeader |
| 64 | icon={<FilesIcon className="h-7 w-7" strokeWidth={2} aria-hidden="true" />} |
| 65 | title="Files" |
| 66 | description="Your WebDAV file tree." |
| 67 | actions={ |
| 68 | <Button |
| 69 | variant="primary" |
| 70 | leftIcon={<FolderPlus className="h-4 w-4" aria-hidden="true" />} |
| 71 | onClick={() => setMkdirOpen(true)} |
| 72 | aria-label="New folder" |
| 73 | > |
| 74 | <span className="hidden sm:inline">New folder</span> |
| 75 | </Button> |
| 76 | } |
| 77 | /> |
| 78 | |
| 79 | <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400"> |
| 80 | <button |
| 81 | type="button" |
| 82 | onClick={() => goTo("/files/")} |
| 83 | className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-zinc-800/70 hover:text-zinc-100" |
| 84 | > |
| 85 | <Home className="h-3.5 w-3.5" aria-hidden="true" /> |
| 86 | files |
| 87 | </button> |
| 88 | {breadcrumbs.slice(1).map((crumb, i) => { |
| 89 | const isCurrent = i === breadcrumbs.length - 2; |
| 90 | return ( |
| 91 | <span key={crumb.path} className="flex items-center gap-1"> |
| 92 | <ChevronRight className="h-3.5 w-3.5 text-zinc-600" aria-hidden="true" /> |
| 93 | <button |
| 94 | type="button" |
| 95 | onClick={() => goTo(crumb.path)} |
| 96 | aria-current={isCurrent ? "page" : undefined} |
| 97 | className={`rounded-md px-2 py-1 transition-colors hover:bg-zinc-800/70 hover:text-zinc-100 ${ |
| 98 | isCurrent ? "text-zinc-200" : "" |
| 99 | }`} |
| 100 | > |
| 101 | {crumb.label} |
| 102 | </button> |
| 103 | </span> |
| 104 | ); |
| 105 | })} |
| 106 | </nav> |
| 107 | |
| 108 | {listing.isPending ? ( |
| 109 | <div className="space-y-2"> |
| 110 | <Skeleton className="h-12 w-full" /> |
| 111 | <Skeleton className="h-12 w-full" /> |
| 112 | <Skeleton className="h-12 w-full" /> |
| 113 | </div> |
| 114 | ) : listing.error ? ( |
| 115 | <ErrorBanner error={listing.error} title="Could not list this folder" /> |
| 116 | ) : ( |
| 117 | <FileTable |
| 118 | entries={listing.data?.entries ?? []} |
| 119 | parent={parent} |
| 120 | onNavigate={goTo} |
| 121 | onRename={setRenameTarget} |
| 122 | currentDir={dir} |
| 123 | /> |
| 124 | )} |
| 125 | |
| 126 | <MkdirDialog open={mkdirOpen} onClose={() => setMkdirOpen(false)} parentDir={dir} /> |
| 127 | <RenameDialog |
| 128 | open={renameTarget != null} |
| 129 | node={renameTarget} |
| 130 | onClose={() => setRenameTarget(null)} |
| 131 | currentDir={dir} |
| 132 | /> |
| 133 | </div> |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | function FileTable({ |
| 138 | entries, |
| 139 | parent, |
| 140 | onNavigate, |
| 141 | onRename, |
| 142 | currentDir, |
| 143 | }: { |
| 144 | entries: FileNode[]; |
| 145 | parent: string | null; |
| 146 | onNavigate: (path: string) => void; |
| 147 | onRename: (node: FileNode) => void; |
| 148 | currentDir: string; |
| 149 | }) { |
| 150 | if (entries.length === 0 && !parent) { |
| 151 | return ( |
| 152 | <EmptyState |
| 153 | icon={<FolderOpen className="h-7 w-7" aria-hidden="true" />} |
| 154 | title="This folder is empty" |
| 155 | description="Use the New folder button above, or upload via a WebDAV client." |
| 156 | /> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | return ( |
| 161 | <div className="overflow-hidden rounded-2xl border border-zinc-800/60"> |
| 162 | <table className="w-full text-sm"> |
| 163 | <thead className="bg-zinc-900/60 text-left text-xs font-medium uppercase tracking-wider text-zinc-500"> |
| 164 | <tr> |
| 165 | <th scope="col" className="px-4 py-2"> |
| 166 | Name |
| 167 | </th> |
| 168 | <th scope="col" className="px-4 py-2"> |
| 169 | Size |
| 170 | </th> |
| 171 | <th scope="col" className="px-4 py-2"> |
| 172 | Modified |
| 173 | </th> |
| 174 | <th scope="col" className="px-4 py-2 text-right"> |
| 175 | Actions |
| 176 | </th> |
| 177 | </tr> |
| 178 | </thead> |
| 179 | <tbody className="divide-y divide-zinc-800/60"> |
| 180 | {parent ? ( |
| 181 | <tr className="transition-colors hover:bg-zinc-800/60"> |
| 182 | <td className="px-4 py-2"> |
| 183 | <button |
| 184 | type="button" |
| 185 | onClick={() => onNavigate(parent)} |
| 186 | className="inline-flex items-center gap-2 text-zinc-300 hover:text-accent-400" |
| 187 | > |
| 188 | <Folder className="h-4 w-4 text-zinc-500" aria-hidden="true" /> |
| 189 | .. |
| 190 | </button> |
| 191 | </td> |
| 192 | <td className="px-4 py-2 text-zinc-500">โ</td> |
| 193 | <td className="px-4 py-2 text-zinc-500">โ</td> |
| 194 | <td /> |
| 195 | </tr> |
| 196 | ) : null} |
| 197 | {entries.map((node) => ( |
| 198 | <FileRow key={node.path} node={node} onNavigate={onNavigate} onRename={onRename} currentDir={currentDir} /> |
| 199 | ))} |
| 200 | </tbody> |
| 201 | </table> |
| 202 | </div> |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | function FileRow({ |
| 207 | node, |
| 208 | onNavigate, |
| 209 | onRename, |
| 210 | currentDir, |
| 211 | }: { |
| 212 | node: FileNode; |
| 213 | onNavigate: (path: string) => void; |
| 214 | onRename: (node: FileNode) => void; |
| 215 | currentDir: string; |
| 216 | }) { |
| 217 | const queryClient = useQueryClient(); |
| 218 | const [confirming, setConfirming] = useState(false); |
| 219 | const remove = $api.useMutation("delete", "/api/v1/files/delete", { |
| 220 | onSuccess: () => { |
| 221 | toast.success(`Deleted ${node.name}.`); |
| 222 | void queryClient.invalidateQueries({ |
| 223 | queryKey: ["get", "/api/v1/files/list", { params: { query: { path: currentDir } } }], |
| 224 | }); |
| 225 | void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/files/list"] }); |
| 226 | setConfirming(false); |
| 227 | }, |
| 228 | }); |
| 229 | |
| 230 | const isDir = node.kind === "collection"; |
| 231 | const Icon = isDir ? Folder : FileIcon; |
| 232 | |
| 233 | return ( |
| 234 | <tr className="transition-colors hover:bg-zinc-800/60"> |
| 235 | <td className="px-4 py-2"> |
| 236 | {isDir ? ( |
| 237 | <button |
| 238 | type="button" |
| 239 | onClick={() => onNavigate(node.path.endsWith("/") ? node.path : `${node.path}/`)} |
| 240 | className="inline-flex items-center gap-2 text-zinc-200 hover:text-accent-400" |
| 241 | > |
| 242 | <Icon className="h-4 w-4 text-accent-400" aria-hidden="true" /> |
| 243 | <span className="font-medium">{node.name}</span> |
| 244 | </button> |
| 245 | ) : ( |
| 246 | <span className="inline-flex items-center gap-2 text-zinc-200"> |
| 247 | <Icon className="h-4 w-4 text-zinc-500" aria-hidden="true" /> |
| 248 | {node.name} |
| 249 | </span> |
| 250 | )} |
| 251 | </td> |
| 252 | <td className="px-4 py-2 text-zinc-400">{isDir ? "โ" : formatBytes(node.size)}</td> |
| 253 | <td className="px-4 py-2 text-zinc-400">{formatDate(node.modified_at, { relative: true })}</td> |
| 254 | <td className="px-4 py-2"> |
| 255 | <div className="flex justify-end gap-1"> |
| 256 | <Button |
| 257 | size="sm" |
| 258 | variant="ghost" |
| 259 | aria-label={`Rename ${node.name}`} |
| 260 | leftIcon={<Pencil className="h-3 w-3" aria-hidden="true" />} |
| 261 | onClick={() => onRename(node)} |
| 262 | > |
| 263 | <span className="sr-only">Rename</span> |
| 264 | </Button> |
| 265 | <Button |
| 266 | size="sm" |
| 267 | variant="ghost" |
| 268 | aria-label={`Delete ${node.name}`} |
| 269 | leftIcon={<Trash2 className="h-3 w-3 text-red-400" aria-hidden="true" />} |
| 270 | onClick={() => setConfirming(true)} |
| 271 | > |
| 272 | <span className="sr-only">Delete</span> |
| 273 | </Button> |
| 274 | </div> |
| 275 | </td> |
| 276 | <ConfirmDialog |
| 277 | open={confirming} |
| 278 | onClose={() => setConfirming(false)} |
| 279 | onConfirm={() => remove.mutate({ body: { path: node.path } })} |
| 280 | title={`Delete ${node.name}?`} |
| 281 | description={isDir ? "All contents will be removed. This cannot be undone." : "This cannot be undone."} |
| 282 | confirmLabel={isDir ? "Delete folder" : "Delete file"} |
| 283 | destructive |
| 284 | loading={remove.isPending} |
| 285 | > |
| 286 | <p className="text-xs text-zinc-500"> |
| 287 | Path: <span className="font-mono text-zinc-300">{node.path}</span> |
| 288 | </p> |
| 289 | </ConfirmDialog> |
| 290 | </tr> |
| 291 | ); |
| 292 | } |
| 293 | |
| 294 | function MkdirDialog({ open, onClose, parentDir }: { open: boolean; onClose: () => void; parentDir: string }) { |
| 295 | const [name, setName] = useState(""); |
| 296 | const queryClient = useQueryClient(); |
| 297 | const create = $api.useMutation("post", "/api/v1/files/mkdir", { |
| 298 | onSuccess: () => { |
| 299 | toast.success(`Created ${name}.`); |
| 300 | void queryClient.invalidateQueries({ |
| 301 | queryKey: ["get", "/api/v1/files/list", { params: { query: { path: parentDir } } }], |
| 302 | }); |
| 303 | void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/files/list"] }); |
| 304 | setName(""); |
| 305 | onClose(); |
| 306 | }, |
| 307 | }); |
| 308 | |
| 309 | const submit = (event: FormEvent) => { |
| 310 | event.preventDefault(); |
| 311 | const segment = name.trim(); |
| 312 | if (!segment) return; |
| 313 | const path = `${parentDir}${segment}/`; |
| 314 | create.mutate({ body: { path } }); |
| 315 | }; |
| 316 | |
| 317 | return ( |
| 318 | <Dialog |
| 319 | open={open} |
| 320 | onClose={() => { |
| 321 | if (!create.isPending) onClose(); |
| 322 | }} |
| 323 | title="New folder" |
| 324 | description={`Create inside ${parentDir}`} |
| 325 | footer={ |
| 326 | <> |
| 327 | <Button variant="ghost" onClick={onClose} disabled={create.isPending}> |
| 328 | Cancel |
| 329 | </Button> |
| 330 | <Button variant="primary" type="submit" form="mkdir-form" loading={create.isPending} disabled={!name.trim()}> |
| 331 | Create |
| 332 | </Button> |
| 333 | </> |
| 334 | } |
| 335 | > |
| 336 | <form id="mkdir-form" onSubmit={submit} className="space-y-4"> |
| 337 | <Label> |
| 338 | Folder name |
| 339 | <Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="documents" required /> |
| 340 | </Label> |
| 341 | {create.error ? <ErrorBanner error={create.error} /> : null} |
| 342 | </form> |
| 343 | </Dialog> |
| 344 | ); |
| 345 | } |
| 346 | |
| 347 | function RenameDialog({ |
| 348 | open, |
| 349 | node, |
| 350 | onClose, |
| 351 | currentDir, |
| 352 | }: { |
| 353 | open: boolean; |
| 354 | node: FileNode | null; |
| 355 | onClose: () => void; |
| 356 | currentDir: string; |
| 357 | }) { |
| 358 | const [newName, setNewName] = useState(""); |
| 359 | const queryClient = useQueryClient(); |
| 360 | const rename = $api.useMutation("post", "/api/v1/files/rename", { |
| 361 | onSuccess: () => { |
| 362 | toast.success(`Renamed ${node?.name}.`); |
| 363 | void queryClient.invalidateQueries({ |
| 364 | queryKey: ["get", "/api/v1/files/list", { params: { query: { path: currentDir } } }], |
| 365 | }); |
| 366 | void queryClient.invalidateQueries({ queryKey: ["get", "/api/v1/files/list"] }); |
| 367 | setNewName(""); |
| 368 | onClose(); |
| 369 | }, |
| 370 | }); |
| 371 | |
| 372 | if (!node) return null; |
| 373 | if (open && !newName) setNewName(node.name); |
| 374 | |
| 375 | const submit = (event: FormEvent) => { |
| 376 | event.preventDefault(); |
| 377 | const target = newName.trim(); |
| 378 | if (!target || target === node.name) return; |
| 379 | const to = `${currentDir}${target}${node.kind === "collection" ? "/" : ""}`; |
| 380 | rename.mutate({ body: { from: node.path, to } }); |
| 381 | }; |
| 382 | |
| 383 | return ( |
| 384 | <Dialog |
| 385 | open={open} |
| 386 | onClose={() => { |
| 387 | if (!rename.isPending) { |
| 388 | setNewName(""); |
| 389 | onClose(); |
| 390 | } |
| 391 | }} |
| 392 | title={`Rename ${node.name}`} |
| 393 | description={`Inside ${currentDir}`} |
| 394 | footer={ |
| 395 | <> |
| 396 | <Button |
| 397 | variant="ghost" |
| 398 | onClick={() => { |
| 399 | setNewName(""); |
| 400 | onClose(); |
| 401 | }} |
| 402 | disabled={rename.isPending} |
| 403 | > |
| 404 | Cancel |
| 405 | </Button> |
| 406 | <Button |
| 407 | variant="primary" |
| 408 | type="submit" |
| 409 | form="rename-form" |
| 410 | loading={rename.isPending} |
| 411 | disabled={!newName.trim() || newName.trim() === node.name} |
| 412 | > |
| 413 | Rename |
| 414 | </Button> |
| 415 | </> |
| 416 | } |
| 417 | > |
| 418 | <form id="rename-form" onSubmit={submit} className="space-y-4"> |
| 419 | <Label> |
| 420 | New name |
| 421 | <Input autoFocus value={newName} onChange={(e) => setNewName(e.target.value)} required /> |
| 422 | </Label> |
| 423 | {rename.error ? <ErrorBanner error={rename.error} /> : null} |
| 424 | </form> |
| 425 | </Dialog> |
| 426 | ); |
| 427 | } |