1"use client";
2
3
19import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20import Link from "next/link";
21import { useParams } from "next/navigation";
22import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23import {
24 AlertTriangle,
25 File as FileIcon,
26 FileAudio,
27 FileImage,
28 FileText,
29 FileVideo,
30 History,
31 Loader2,
32 RotateCcw,
33 Trash2
34} from "lucide-react";
35import { filesApi, humanSize, kindForMime, type FileKind, type FileListItem, type FileStatus, type TrashedFile } from "@/lib/files/api";
36import { PreviewModal } from "./PreviewModal";
37import { cn } from "@/lib/utils";
38import { toast } from "@/stores/ui-store";
39import { ConfirmDialog } from "@/components/ui/confirm-dialog";
40
41const KIND_ICONS: Record<FileKind, typeof FileIcon> = {
42 image: FileImage,
43 video: FileVideo,
44 audio: FileAudio,
45 pdf: FileText,
46 doc: FileText,
47 other: FileIcon
48};
49
50const KINDS: FileKind[] = ["image", "video", "audio", "pdf", "doc", "other"];
51const STATUSES: FileStatus[] = ["UPLOADING", "SCANNING", "READY", "VIRUS_DETECTED", "FAILED"];
52const SORTS = ["updated", "name", "size", "date"] as const;
53
54type Tab = "files" | "trash";
55
56interface FileBrowserProps {
57 workspaceId: string;
58}
59
60export function FileBrowser({ workspaceId }: FileBrowserProps) {
61 const params = useParams<{ workspace: string }>();
62 const slug = params?.workspace ?? "default";
63
64 const [tab, setTab] = useState<Tab>("files");
65 const [kind, setKind] = useState<FileKind | "all">("all");
66 const [status, setStatus] = useState<FileStatus | "all">("all");
67 const [sort, setSort] = useState<(typeof SORTS)[number]>("updated");
68 const [preview, setPreview] = useState<string | null>(null);
69 const [versionsFor, setVersionsFor] = useState<string | null>(null);
70
71 const fetchPage = useCallback(
72 async (cursor: string | undefined) => {
73 const res = await filesApi.list({
74 workspaceId,
75 scope: "workspace",
76 kind: kind === "all" ? undefined : kind,
77 status: status === "all" ? undefined : status,
78 sort,
79 limit: 50,
80 cursor
81 });
82 return { data: res.data, nextCursor: res.nextCursor };
83 },
84 [workspaceId, kind, status, sort]
85 );
86
87
88
89
90 const query = useInfiniteQuery({
91 queryKey: ["files", "browser", workspaceId, kind, status, sort],
92 queryFn: ({ pageParam }) => fetchPage(pageParam),
93 initialPageParam: undefined as string | undefined,
94 getNextPageParam: (last) => last.nextCursor ?? undefined,
95 enabled: tab === "files"
96 });
97
98 const rows = useMemo(() => query.data?.pages.flatMap((p) => p.data) ?? [], [query.data]);
99 const lastPage = query.data?.pages[query.data.pages.length - 1];
100 const hasMore = Boolean(lastPage?.nextCursor);
101
102 const openVersions = (fileId: string) => setVersionsFor(fileId);
103
104 return (
105 <div className="mx-auto w-full max-w-6xl px-4 py-6">
106 <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
107 <h1 className="text-xl font-semibold">Files</h1>
108 <nav aria-label="Files view" className="flex rounded-md border border-border p-0.5 text-sm">
109 <TabButton active={tab === "files"} onClick={() => setTab("files")}>
110 All files
111 </TabButton>
112 <TabButton active={tab === "trash"} onClick={() => setTab("trash")}>
113 Trash
114 </TabButton>
115 </nav>
116 </div>
117
118 {tab === "files" ? (
119 <>
120 <div className="mb-3 flex flex-wrap items-center gap-2">
121 <select
122 value={kind}
123 onChange={(e) => setKind(e.target.value as FileKind | "all")}
124 aria-label="Filter by kind"
125 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
126 >
127 <option value="all">All kinds</option>
128 {KINDS.map((k) => (
129 <option key={k} value={k}>
130 {k.charAt(0).toUpperCase() + k.slice(1)}
131 </option>
132 ))}
133 </select>
134 <select
135 value={status}
136 onChange={(e) => setStatus(e.target.value as FileStatus | "all")}
137 aria-label="Filter by status"
138 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
139 >
140 <option value="all">All statuses</option>
141 {STATUSES.map((s) => (
142 <option key={s} value={s}>
143 {s}
144 </option>
145 ))}
146 </select>
147 <select
148 value={sort}
149 onChange={(e) => setSort(e.target.value as (typeof SORTS)[number])}
150 aria-label="Sort by"
151 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
152 >
153 {SORTS.map((s) => (
154 <option key={s} value={s}>
155 {s === "updated" ? "Recently updated" : s === "date" ? "Newest" : s === "size" ? "Largest" : "Name"}
156 </option>
157 ))}
158 </select>
159 </div>
160
161 {query.isLoading ? (
162 <div className="flex h-40 items-center justify-center">
163 <Loader2 className="size-5 animate-spin text-muted-foreground" aria-hidden />
164 </div>
165 ) : query.isError ? (
166 <div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
167 <AlertTriangle className="size-5" aria-hidden />
168 Could not load files. Try again.
169 </div>
170 ) : rows.length === 0 ? (
171 <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
172 No files here yet — drag one into a page to get started.
173 </div>
174 ) : (
175 <>
176 {}
177 <div className="hidden overflow-x-auto rounded-md border border-border md:block">
178 <table className="w-full text-sm">
179 <thead className="border-b border-border bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
180 <tr>
181 <th className="px-3 py-2 font-medium">Name</th>
182 <th className="px-3 py-2 font-medium">Size</th>
183 <th className="px-3 py-2 font-medium">Uploader</th>
184 <th className="px-3 py-2 font-medium">Page</th>
185 <th className="px-3 py-2 font-medium">Status</th>
186 <th className="px-3 py-2 font-medium">Updated</th>
187 <th className="px-3 py-2 text-right font-medium">
188 <span className="sr-only">Versions</span>
189 </th>
190 </tr>
191 </thead>
192 <tbody>
193 {rows.map((f) => (
194 <FileRow
195 key={f.id}
196 file={f}
197 slug={slug}
198 onOpen={() => setPreview(f.id)}
199 onVersions={() => openVersions(f.id)}
200 />
201 ))}
202 </tbody>
203 </table>
204 </div>
205
206 {}
207 <ul className="space-y-2 md:hidden">
208 {rows.map((f) => (
209 <FileCard key={f.id} file={f} onOpen={() => setPreview(f.id)} onVersions={() => openVersions(f.id)} />
210 ))}
211 </ul>
212
213 {hasMore ? (
214 <div className="mt-4 flex justify-center">
215 <button
216 type="button"
217 onClick={() => void query.fetchNextPage()}
218 disabled={query.isFetchingNextPage}
219 className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent disabled:opacity-50"
220 >
221 {query.isFetchingNextPage ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : null}
222 Load more
223 </button>
224 </div>
225 ) : null}
226 </>
227 )}
228 </>
229 ) : (
230 <TrashTab workspaceId={workspaceId} />
231 )}
232
233 {preview ? (
234 <PreviewModal fileId={preview} workspaceId={workspaceId} onClose={() => setPreview(null)} />
235 ) : null}
236
237 {versionsFor ? <VersionsMenu fileId={versionsFor} workspaceId={workspaceId} onClose={() => setVersionsFor(null)} /> : null}
238 </div>
239 );
240}
241
242function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
243 return (
244 <button
245 type="button"
246 onClick={onClick}
247 aria-current={active ? "page" : undefined}
248 className={cn(
249 "rounded px-3 py-1 font-medium",
250 active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-accent/50"
251 )}
252 >
253 {children}
254 </button>
255 );
256}
257
258
259
260function TrashTab({ workspaceId }: { workspaceId: string }) {
261 const qc = useQueryClient();
262 const [purgeFor, setPurgeFor] = useState<TrashedFile | null>(null);
263
264 const trashQuery = useQuery({
265 queryKey: ["files", "trash", workspaceId],
266 queryFn: () => filesApi.listTrash({ workspaceId }),
267 enabled: workspaceId.length > 0,
268 retry: false
269 });
270
271 const restore = useMutation({
272 mutationFn: (id: string) => filesApi.restore(id, { workspaceId }),
273 onSuccess: () => {
274 qc.invalidateQueries({ queryKey: ["files", "trash", workspaceId] });
275 qc.invalidateQueries({ queryKey: ["files", "browser", workspaceId] });
276 toast("success", "File restored");
277 },
278 onError: (e) => toast("error", "Restore failed", e instanceof Error ? e.message : undefined)
279 });
280
281 const purge = useMutation({
282 mutationFn: (id: string) => filesApi.purge(id, { workspaceId }),
283 onSuccess: () => {
284 qc.invalidateQueries({ queryKey: ["files", "trash", workspaceId] });
285 toast("success", "File permanently deleted");
286 },
287 onError: (e) => toast("error", "Purge failed", e instanceof Error ? e.message : undefined)
288 });
289
290 const items = trashQuery.data ?? [];
291
292 return (
293 <div>
294 <p className="mb-3 text-sm text-muted-foreground">
295 Trashed files are kept and can be restored. Purging deletes the file and all of its versions for good.
296 </p>
297 {trashQuery.isLoading ? (
298 <div className="flex h-40 items-center justify-center">
299 <Loader2 className="size-5 animate-spin text-muted-foreground" aria-hidden />
300 </div>
301 ) : trashQuery.isError ? (
302 <div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
303 <AlertTriangle className="size-5" aria-hidden />
304 Could not load the trash. Try again.
305 </div>
306 ) : items.length === 0 ? (
307 <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
308 The trash is empty.
309 </div>
310 ) : (
311 <>
312 {}
313 <div className="hidden overflow-x-auto rounded-md border border-border md:block">
314 <table className="w-full text-sm">
315 <thead className="border-b border-border bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
316 <tr>
317 <th className="px-3 py-2 font-medium">Name</th>
318 <th className="px-3 py-2 font-medium">Size</th>
319 <th className="px-3 py-2 font-medium">Deleted by</th>
320 <th className="px-3 py-2 font-medium">Deleted</th>
321 <th className="px-3 py-2 text-right font-medium">
322 <span className="sr-only">Actions</span>
323 </th>
324 </tr>
325 </thead>
326 <tbody>
327 {items.map((f) => (
328 <tr key={f.id} className="border-b border-border last:border-0">
329 <td className="max-w-[280px] px-3 py-2">
330 <span className="flex items-center gap-2">
331 <Trash2 className="size-4 shrink-0 text-muted-foreground" aria-hidden />
332 <span className="truncate font-medium">{f.name}</span>
333 </span>
334 </td>
335 <td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{humanSize(f.size)}</td>
336 <td className="max-w-[140px] truncate px-3 py-2 text-muted-foreground">{f.uploadedByName ?? "—"}</td>
337 <td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
338 {f.deletedAt ? new Date(f.deletedAt).toLocaleDateString() : "—"}
339 </td>
340 <td className="whitespace-nowrap px-3 py-2 text-right">
341 <button
342 type="button"
343 onClick={() => restore.mutate(f.id)}
344 disabled={restore.isPending}
345 className="mr-1 inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50"
346 >
347 <RotateCcw className="size-3.5" aria-hidden />
348 Restore
349 </button>
350 <button
351 type="button"
352 onClick={() => setPurgeFor(f)}
353 disabled={purge.isPending}
354 className="inline-flex items-center gap-1 rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950"
355 >
356 <Trash2 className="size-3.5" aria-hidden />
357 Purge
358 </button>
359 </td>
360 </tr>
361 ))}
362 </tbody>
363 </table>
364 </div>
365
366 {}
367 <ul className="space-y-2 md:hidden">
368 {items.map((f) => (
369 <li key={f.id} className="flex items-center gap-3 rounded-md border border-border p-3">
370 <Trash2 className="size-4 shrink-0 text-muted-foreground" aria-hidden />
371 <span className="min-w-0 flex-1">
372 <span className="block truncate text-sm font-medium">{f.name}</span>
373 <span className="block truncate text-xs text-muted-foreground">
374 {humanSize(f.size)}
375 {f.deletedAt ? ` · deleted ${new Date(f.deletedAt).toLocaleDateString()}` : ""}
376 </span>
377 </span>
378 <button
379 type="button"
380 onClick={() => restore.mutate(f.id)}
381 disabled={restore.isPending}
382 className="rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50"
383 >
384 Restore
385 </button>
386 <button
387 type="button"
388 onClick={() => setPurgeFor(f)}
389 disabled={purge.isPending}
390 className="rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:text-red-400"
391 >
392 Purge
393 </button>
394 </li>
395 ))}
396 </ul>
397 </>
398 )}
399
400 <ConfirmDialog
401 open={purgeFor !== null}
402 title="Permanently delete this file?"
403 description={
404 <>
405 <span className="font-medium text-foreground">{purgeFor?.name}</span> and all of its versions will be
406 deleted. This cannot be undone.
407 </>
408 }
409 confirmLabel="Purge"
410 destructive
411 busy={purge.isPending}
412 onConfirm={() => {
413 if (purgeFor) purge.mutate(purgeFor.id, { onSettled: () => setPurgeFor(null) });
414 }}
415 onClose={() => {
416 if (!purge.isPending) setPurgeFor(null);
417 }}
418 />
419 </div>
420 );
421}
422
423
424
425function VersionsMenu({
426 fileId,
427 workspaceId,
428 onClose
429}: {
430 fileId: string;
431 workspaceId: string;
432 onClose: () => void;
433}) {
434 const ref = useRef<HTMLDivElement>(null);
435 const qc = useQueryClient();
436 const [confirm, setConfirm] = useState<{ kind: "rollback" | "prune"; version?: number } | null>(null);
437
438 const versionsQuery = useQuery({
439 queryKey: ["files", fileId, "versions"],
440 queryFn: () => filesApi.listVersions(fileId, { workspaceId }),
441 enabled: !!fileId,
442 retry: false
443 });
444
445 const rollback = useMutation({
446 mutationFn: (v: number) => filesApi.rollback(fileId, v, { workspaceId }),
447 onSuccess: () => {
448 qc.invalidateQueries({ queryKey: ["files", fileId] });
449 qc.invalidateQueries({ queryKey: ["files", fileId, "versions"] });
450 toast("success", "Rolled back");
451 onClose();
452 },
453 onError: (e) => toast("error", "Rollback failed", e instanceof Error ? e.message : undefined)
454 });
455
456 const prune = useMutation({
457 mutationFn: () => filesApi.pruneVersions(fileId, { workspaceId }),
458 onSuccess: (r) => {
459 qc.invalidateQueries({ queryKey: ["files", fileId, "versions"] });
460 qc.invalidateQueries({ queryKey: ["files", fileId] });
461 toast("success", `Deleted ${r.pruned} old version${r.pruned === 1 ? "" : "s"}`);
462 onClose();
463 },
464 onError: (e) => toast("error", "Delete failed", e instanceof Error ? e.message : undefined)
465 });
466
467 useEffect(() => {
468 const onKey = (e: KeyboardEvent) => {
469 if (e.key === "Escape") onClose();
470 };
471 const onClick = (e: MouseEvent) => {
472 if (ref.current && !ref.current.contains(e.target as Node)) onClose();
473 };
474 document.addEventListener("keydown", onKey);
475 document.addEventListener("mousedown", onClick);
476 return () => {
477 document.removeEventListener("keydown", onKey);
478 document.removeEventListener("mousedown", onClick);
479 };
480 }, [onClose]);
481
482 const busy = rollback.isPending || prune.isPending;
483 const versions = versionsQuery.data;
484
485 const runConfirm = () => {
486 if (!confirm) return;
487 if (confirm.kind === "rollback" && confirm.version !== undefined) rollback.mutate(confirm.version);
488 if (confirm.kind === "prune") prune.mutate();
489 setConfirm(null);
490 };
491
492 const confirmTitle =
493 confirm?.kind === "rollback"
494 ? "Roll back to this version?"
495 : "Delete all old versions?";
496 const confirmDesc =
497 confirm?.kind === "rollback" ? (
498 <>
499 The current version becomes the history and version{" "}
500 <span className="font-medium text-foreground">v{confirm.version}</span> becomes current. The bytes are not
501 re-uploaded.
502 </>
503 ) : (
504 <>
505 Every version except the current one is deleted. The current version is kept. This cannot be undone.
506 </>
507 );
508
509 return (
510 <>
511 <div
512 ref={ref}
513 role="dialog"
514 aria-modal="true"
515 aria-label="File versions"
516 className="fixed right-4 top-4 z-[95] w-80 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background p-3 shadow-xl"
517 >
518 <div className="mb-2 flex items-center justify-between">
519 <p className="flex items-center gap-1.5 text-sm font-semibold">
520 <History className="size-4 text-muted-foreground" aria-hidden />
521 Versions
522 </p>
523 <button
524 type="button"
525 onClick={onClose}
526 aria-label="Close versions"
527 className="rounded p-1 text-muted-foreground hover:bg-accent"
528 >
529 ✕
530 </button>
531 </div>
532
533 {versionsQuery.isLoading ? (
534 <p className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
535 <Loader2 className="size-4 animate-spin" aria-hidden /> Loading…
536 </p>
537 ) : versionsQuery.isError ? (
538 <p className="py-2 text-sm text-red-600 dark:text-red-400">
539 Could not load versions ({versionsQuery.error instanceof Error ? versionsQuery.error.message : "unknown"}).
540 </p>
541 ) : versions && versions.length === 0 ? (
542 <p className="py-2 text-sm text-muted-foreground">No versions recorded.</p>
543 ) : versions ? (
544 <ul className="max-h-64 space-y-1 overflow-y-auto">
545 {versions.map((v) => (
546 <li key={v.id} className="flex items-center gap-2 rounded px-1 py-1">
547 <span className={cn("shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium", v.current ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>
548 v{v.version}
549 </span>
550 <span className="min-w-0 flex-1">
551 <span className="block text-xs text-muted-foreground">
552 {humanSize(v.size)}
553 {v.createdByName ? ` · ${v.createdByName}` : ""} · {new Date(v.createdAt).toLocaleDateString()}
554 </span>
555 </span>
556 {v.current ? (
557 <span className="shrink-0 text-[11px] font-medium text-primary">Current</span>
558 ) : (
559 <button
560 type="button"
561 onClick={() => setConfirm({ kind: "rollback", version: v.version })}
562 disabled={busy}
563 className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium hover:bg-accent disabled:opacity-50"
564 >
565 Rollback
566 </button>
567 )}
568 </li>
569 ))}
570 </ul>
571 ) : null}
572
573 <div className="mt-2 flex items-center justify-between border-t border-border pt-2">
574 <p className="text-[11px] text-muted-foreground">
575 {versions && versions.length > 1 ? `${versions.length} versions` : "1 version"}
576 </p>
577 <button
578 type="button"
579 onClick={() => setConfirm({ kind: "prune" })}
580 disabled={busy || !versions || versions.length <= 1}
581 className="rounded border border-red-300 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950"
582 >
583 Delete old versions
584 </button>
585 </div>
586 </div>
587
588 <ConfirmDialog
589 open={confirm !== null}
590 title={confirmTitle}
591 description={confirmDesc}
592 confirmLabel={confirm?.kind === "rollback" ? "Roll back" : "Delete"}
593 destructive={confirm?.kind === "prune"}
594 busy={busy}
595 onConfirm={runConfirm}
596 onClose={() => {
597 if (!busy) setConfirm(null);
598 }}
599 />
600 </>
601 );
602}
603
604
605
606function StatusPill({ status }: { status: FileStatus }) {
607 const tone =
608 status === "READY"
609 ? "bg-emerald-100 text-emerald-800"
610 : status === "VIRUS_DETECTED"
611 ? "bg-red-100 text-red-800"
612 : status === "FAILED"
613 ? "bg-red-50 text-red-700"
614 : "bg-amber-50 text-amber-700";
615 return <span className={cn("inline-block rounded-full px-2 py-0.5 text-[11px] font-medium", tone)}>{status}</span>;
616}
617
618function FileRow({
619 file,
620 slug,
621 onOpen,
622 onVersions
623}: {
624 file: FileListItem;
625 slug: string;
626 onOpen: () => void;
627 onVersions: () => void;
628}) {
629 const Icon = KIND_ICONS[kindForMime(file.mimeType)];
630 return (
631 <tr className="cursor-pointer border-b border-border last:border-0 hover:bg-accent/40" onClick={onOpen}>
632 <td className="max-w-[280px] px-3 py-2">
633 <span className="flex items-center gap-2">
634 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
635 <span className="truncate font-medium">{file.name}</span>
636 </span>
637 </td>
638 <td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{humanSize(file.size)}</td>
639 <td className="max-w-[140px] truncate px-3 py-2 text-muted-foreground">{file.uploadedByName ?? "—"}</td>
640 <td className="max-w-[160px] px-3 py-2">
641 {file.pageId ? (
642 <Link
643 href={`/w/${slug}/page/${file.pageId}`}
644 onClick={(e) => e.stopPropagation()}
645 className="block truncate text-primary hover:underline"
646 >
647 {file.pageName ?? "Page"}
648 </Link>
649 ) : (
650 <span className="text-muted-foreground">—</span>
651 )}
652 </td>
653 <td className="px-3 py-2">
654 <StatusPill status={file.status} />
655 </td>
656 <td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{new Date(file.updatedAt).toLocaleDateString()}</td>
657 <td className="px-3 py-2 text-right">
658 <button
659 type="button"
660 onClick={(e) => {
661 e.stopPropagation();
662 onVersions();
663 }}
664 aria-label={`Versions for ${file.name}`}
665 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent"
666 >
667 <History className="size-3.5" aria-hidden />
668 {file.versionCount > 0 ? file.versionCount : ""}
669 </button>
670 </td>
671 </tr>
672 );
673}
674
675function FileCard({
676 file,
677 onOpen,
678 onVersions
679}: {
680 file: FileListItem;
681 onOpen: () => void;
682 onVersions: () => void;
683}) {
684 const Icon = KIND_ICONS[kindForMime(file.mimeType)];
685 return (
686 <li className="flex items-center gap-3 rounded-md border border-border p-3">
687 <button type="button" onClick={onOpen} className="flex min-w-0 flex-1 items-center gap-3 text-left">
688 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
689 <Icon className="size-4 text-muted-foreground" aria-hidden />
690 </span>
691 <span className="min-w-0 flex-1">
692 <span className="block truncate text-sm font-medium">{file.name}</span>
693 <span className="block truncate text-xs text-muted-foreground">
694 {humanSize(file.size)}
695 {file.uploadedByName ? ` · ${file.uploadedByName}` : ""}
696 </span>
697 </span>
698 <StatusPill status={file.status} />
699 </button>
700 <button
701 type="button"
702 onClick={onVersions}
703 aria-label={`Versions for ${file.name}`}
704 className="shrink-0 rounded-md border border-border p-1.5 text-muted-foreground hover:bg-accent"
705 >
706 <History className="size-4" aria-hidden />
707 </button>
708 </li>
709 );
710}
711