Skip to content
File

Blob: src/client/pages/files.tsx

typescript428 lines
1import { type FormEvent, useMemo, useState } from "react";
2import { useQueryClient } from "@tanstack/react-query";
3import { ChevronRight, FileIcon, FilesIcon, Folder, FolderOpen, FolderPlus, Home, Pencil, Trash2 } from "lucide-react";
4import { useNavigate, useSearch } from "@tanstack/react-router";
5 
6import { $api } from "@/client/lib/api";
7import type { FileNode } from "@/client/lib/types";
8import { Button } from "@/client/components/ui/button";
9import { ConfirmDialog } from "@/client/components/ui/confirm-dialog";
10import { Dialog } from "@/client/components/ui/dialog";
11import { EmptyState } from "@/client/components/empty-state";
12import { ErrorBanner } from "@/client/components/error-banner";
13import { Input } from "@/client/components/ui/input";
14import { Label } from "@/client/components/ui/label";
15import { PageHeader } from "@/client/components/ui/page-header";
16import { Skeleton } from "@/client/components/loading-panel";
17import { formatBytes, formatDate } from "@/client/lib/format";
18import { paths } from "@/client/lib/paths";
19import { toast } from "@/client/lib/toast";
20 
21function 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 
29function 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 
36function 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 
47export 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 
137function 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 
206function 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 
294function 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 
347function 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}