"use client"; /** * TASK-0103/0104 — the file browser (workspace route, Requirements §5). * * Two tabs: * - Files: table of workspace files (name, kind icon, size, uploader, page, * status, updated), sortable (name/size/date/updated), filterable by kind * + status, cursor-paginated 50/page, row click → preview modal + "Open * page" link. Each row/card also has a VERSIONS menu (list versions + * Rollback + "Delete old versions" admin action, both confirmed). * - Trash: `GET /files/trash` — trashed files with Restore / Purge (purge is * a destructive confirm). * Mobile (≤ 640 px): a card list instead of the table (both tabs). * * Server-side permission filtering (Requirement §6) is done by the endpoints — * this component only renders what it is given. The frontend never authorizes. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, File as FileIcon, FileAudio, FileImage, FileText, FileVideo, History, Loader2, RotateCcw, Trash2 } from "lucide-react"; import { filesApi, humanSize, kindForMime, type FileKind, type FileListItem, type FileStatus, type TrashedFile } from "@/lib/files/api"; import { PreviewModal } from "./PreviewModal"; import { cn } from "@/lib/utils"; import { toast } from "@/stores/ui-store"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; const KIND_ICONS: Record = { image: FileImage, video: FileVideo, audio: FileAudio, pdf: FileText, doc: FileText, other: FileIcon }; const KINDS: FileKind[] = ["image", "video", "audio", "pdf", "doc", "other"]; const STATUSES: FileStatus[] = ["UPLOADING", "SCANNING", "READY", "VIRUS_DETECTED", "FAILED"]; const SORTS = ["updated", "name", "size", "date"] as const; type Tab = "files" | "trash"; interface FileBrowserProps { workspaceId: string; } export function FileBrowser({ workspaceId }: FileBrowserProps) { const params = useParams<{ workspace: string }>(); const slug = params?.workspace ?? "default"; const [tab, setTab] = useState("files"); const [kind, setKind] = useState("all"); const [status, setStatus] = useState("all"); const [sort, setSort] = useState<(typeof SORTS)[number]>("updated"); const [preview, setPreview] = useState(null); const [versionsFor, setVersionsFor] = useState(null); const fetchPage = useCallback( async (cursor: string | undefined) => { const res = await filesApi.list({ workspaceId, scope: "workspace", kind: kind === "all" ? undefined : kind, status: status === "all" ? undefined : status, sort, limit: 50, cursor }); return { data: res.data, nextCursor: res.nextCursor }; }, [workspaceId, kind, status, sort] ); // `initialPageParam` is typed `string | undefined` so `getNextPageParam` // can return a real cursor (a bare `undefined` would lock PageParam to // `undefined`). const query = useInfiniteQuery({ queryKey: ["files", "browser", workspaceId, kind, status, sort], queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: undefined as string | undefined, getNextPageParam: (last) => last.nextCursor ?? undefined, enabled: tab === "files" }); const rows = useMemo(() => query.data?.pages.flatMap((p) => p.data) ?? [], [query.data]); const lastPage = query.data?.pages[query.data.pages.length - 1]; const hasMore = Boolean(lastPage?.nextCursor); const openVersions = (fileId: string) => setVersionsFor(fileId); return (

Files

{tab === "files" ? ( <>
{query.isLoading ? (
) : query.isError ? (
Could not load files. Try again.
) : rows.length === 0 ? (
No files here yet — drag one into a page to get started.
) : ( <> {/* desktop table (hidden ≤ 640 px) */}
{rows.map((f) => ( setPreview(f.id)} onVersions={() => openVersions(f.id)} /> ))}
Name Size Uploader Page Status Updated Versions
{/* mobile card list (shown ≤ 640 px) */}
    {rows.map((f) => ( setPreview(f.id)} onVersions={() => openVersions(f.id)} /> ))}
{hasMore ? (
) : null} )} ) : ( )} {preview ? ( setPreview(null)} /> ) : null} {versionsFor ? setVersionsFor(null)} /> : null}
); } function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { return ( ); } /* =============================================================== trash */ function TrashTab({ workspaceId }: { workspaceId: string }) { const qc = useQueryClient(); const [purgeFor, setPurgeFor] = useState(null); const trashQuery = useQuery({ queryKey: ["files", "trash", workspaceId], queryFn: () => filesApi.listTrash({ workspaceId }), enabled: workspaceId.length > 0, retry: false }); const restore = useMutation({ mutationFn: (id: string) => filesApi.restore(id, { workspaceId }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["files", "trash", workspaceId] }); qc.invalidateQueries({ queryKey: ["files", "browser", workspaceId] }); toast("success", "File restored"); }, onError: (e) => toast("error", "Restore failed", e instanceof Error ? e.message : undefined) }); const purge = useMutation({ mutationFn: (id: string) => filesApi.purge(id, { workspaceId }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["files", "trash", workspaceId] }); toast("success", "File permanently deleted"); }, onError: (e) => toast("error", "Purge failed", e instanceof Error ? e.message : undefined) }); const items = trashQuery.data ?? []; return (

Trashed files are kept and can be restored. Purging deletes the file and all of its versions for good.

{trashQuery.isLoading ? (
) : trashQuery.isError ? (
Could not load the trash. Try again.
) : items.length === 0 ? (
The trash is empty.
) : ( <> {/* desktop table (hidden ≤ 640 px) */}
{items.map((f) => ( ))}
Name Size Deleted by Deleted Actions
{f.name} {humanSize(f.size)} {f.uploadedByName ?? "—"} {f.deletedAt ? new Date(f.deletedAt).toLocaleDateString() : "—"}
{/* mobile card list (shown ≤ 640 px) */}
    {items.map((f) => (
  • {f.name} {humanSize(f.size)} {f.deletedAt ? ` · deleted ${new Date(f.deletedAt).toLocaleDateString()}` : ""}
  • ))}
)} {purgeFor?.name} and all of its versions will be deleted. This cannot be undone. } confirmLabel="Purge" destructive busy={purge.isPending} onConfirm={() => { if (purgeFor) purge.mutate(purgeFor.id, { onSettled: () => setPurgeFor(null) }); }} onClose={() => { if (!purge.isPending) setPurgeFor(null); }} />
); } /* =========================================================== versions */ function VersionsMenu({ fileId, workspaceId, onClose }: { fileId: string; workspaceId: string; onClose: () => void; }) { const ref = useRef(null); const qc = useQueryClient(); const [confirm, setConfirm] = useState<{ kind: "rollback" | "prune"; version?: number } | null>(null); const versionsQuery = useQuery({ queryKey: ["files", fileId, "versions"], queryFn: () => filesApi.listVersions(fileId, { workspaceId }), enabled: !!fileId, retry: false }); const rollback = useMutation({ mutationFn: (v: number) => filesApi.rollback(fileId, v, { workspaceId }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["files", fileId] }); qc.invalidateQueries({ queryKey: ["files", fileId, "versions"] }); toast("success", "Rolled back"); onClose(); }, onError: (e) => toast("error", "Rollback failed", e instanceof Error ? e.message : undefined) }); const prune = useMutation({ mutationFn: () => filesApi.pruneVersions(fileId, { workspaceId }), onSuccess: (r) => { qc.invalidateQueries({ queryKey: ["files", fileId, "versions"] }); qc.invalidateQueries({ queryKey: ["files", fileId] }); toast("success", `Deleted ${r.pruned} old version${r.pruned === 1 ? "" : "s"}`); onClose(); }, onError: (e) => toast("error", "Delete failed", e instanceof Error ? e.message : undefined) }); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; document.addEventListener("keydown", onKey); document.addEventListener("mousedown", onClick); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("mousedown", onClick); }; }, [onClose]); const busy = rollback.isPending || prune.isPending; const versions = versionsQuery.data; const runConfirm = () => { if (!confirm) return; if (confirm.kind === "rollback" && confirm.version !== undefined) rollback.mutate(confirm.version); if (confirm.kind === "prune") prune.mutate(); setConfirm(null); }; const confirmTitle = confirm?.kind === "rollback" ? "Roll back to this version?" : "Delete all old versions?"; const confirmDesc = confirm?.kind === "rollback" ? ( <> The current version becomes the history and version{" "} v{confirm.version} becomes current. The bytes are not re-uploaded. ) : ( <> Every version except the current one is deleted. The current version is kept. This cannot be undone. ); return ( <>

Versions

{versionsQuery.isLoading ? (

Loading…

) : versionsQuery.isError ? (

Could not load versions ({versionsQuery.error instanceof Error ? versionsQuery.error.message : "unknown"}).

) : versions && versions.length === 0 ? (

No versions recorded.

) : versions ? (
    {versions.map((v) => (
  • v{v.version} {humanSize(v.size)} {v.createdByName ? ` · ${v.createdByName}` : ""} · {new Date(v.createdAt).toLocaleDateString()} {v.current ? ( Current ) : ( )}
  • ))}
) : null}

{versions && versions.length > 1 ? `${versions.length} versions` : "1 version"}

{ if (!busy) setConfirm(null); }} /> ); } /* ===================================================== rows + cards */ function StatusPill({ status }: { status: FileStatus }) { const tone = status === "READY" ? "bg-emerald-100 text-emerald-800" : status === "VIRUS_DETECTED" ? "bg-red-100 text-red-800" : status === "FAILED" ? "bg-red-50 text-red-700" : "bg-amber-50 text-amber-700"; return {status}; } function FileRow({ file, slug, onOpen, onVersions }: { file: FileListItem; slug: string; onOpen: () => void; onVersions: () => void; }) { const Icon = KIND_ICONS[kindForMime(file.mimeType)]; return ( {file.name} {humanSize(file.size)} {file.uploadedByName ?? "—"} {file.pageId ? ( e.stopPropagation()} className="block truncate text-primary hover:underline" > {file.pageName ?? "Page"} ) : ( — )} {new Date(file.updatedAt).toLocaleDateString()} ); } function FileCard({ file, onOpen, onVersions }: { file: FileListItem; onOpen: () => void; onVersions: () => void; }) { const Icon = KIND_ICONS[kindForMime(file.mimeType)]; return (
  • ); }