"use client"; import { ArrowDownWideNarrow, ArrowUpNarrowWide, File, FileArchive, FileAudio, FileCode, FileImage, FileSpreadsheet, FileText, FileVideo, Folder, HardDrive, LayoutGrid, List, Upload, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode, useCallback, useMemo, useRef, useState, } from "react"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/motion/breadcrumb"; import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/motion/dropdown-menu"; import { Progress } from "@/components/motion/progress"; import { ToggleGroup, ToggleGroupItem } from "@/components/motion/toggle-group"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type FileKind = | "folder" | "image" | "video" | "audio" | "document" | "code" | "archive" | "spreadsheet" | "other"; export interface FileNode { id: string; name: string; kind: FileKind; /** Bytes. Folders show their item count instead. */ size?: number; /** ISO date string, formatted with `locale` in UTC. */ modified?: string; children?: FileNode[]; } export type FileView = "grid" | "list"; export type FileSortKey = "name" | "modified" | "size" | "kind"; export type FileSort = { key: FileSortKey; direction: "asc" | "desc" }; export interface FileManagerProps { /** The root folder. Dropped files are added to the internal copy. */ root: FileNode; /** Controlled path: ids from the root's child down to the open folder. */ path?: string[]; defaultPath?: string[]; onPathChange?: (path: string[]) => void; view?: FileView; defaultView?: FileView; onViewChange?: (view: FileView) => void; /** Controlled selection (ids in the open folder). */ selection?: string[]; defaultSelection?: string[]; onSelectionChange?: (ids: string[]) => void; defaultSort?: FileSort; /** Fires when a file (not a folder) is opened by double-click or Enter. */ onOpenFile?: (file: FileNode, path: string[]) => void; /** Fires with dropped files and the folder they landed in. */ onUpload?: (files: File[], folderId: string) => void; /** Storage meter in bytes. Omit to hide the footer meter. */ storage?: { used: number; total: number }; /** Default "en-US" so server and client agree. */ locale?: string; className?: string; } function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } const KIND_ICON: Record = { folder: Folder, image: FileImage, video: FileVideo, audio: FileAudio, document: FileText, code: FileCode, archive: FileArchive, spreadsheet: FileSpreadsheet, other: File, }; // Each kind tints its icon with one chart token so types read at a glance. const KIND_TINT: Record = { folder: "text-chart-1", image: "text-chart-5", video: "text-chart-2", audio: "text-chart-3", document: "text-muted-foreground", code: "text-chart-3", archive: "text-chart-4", spreadsheet: "text-chart-3", other: "text-muted-foreground", }; const EXT_KIND: Record = { png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", mp4: "video", mov: "video", webm: "video", mp3: "audio", wav: "audio", flac: "audio", pdf: "document", doc: "document", docx: "document", txt: "document", md: "document", ts: "code", tsx: "code", js: "code", json: "code", css: "code", html: "code", py: "code", zip: "archive", gz: "archive", tar: "archive", rar: "archive", csv: "spreadsheet", xls: "spreadsheet", xlsx: "spreadsheet", }; /** Best-guess kind from a file name's extension. */ export function fileKindFromName(name: string): FileKind { const ext = name.split(".").pop()?.toLowerCase() ?? ""; return EXT_KIND[ext] ?? "other"; } /** "1.2 MB"-style size, en-US digits. */ export function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`; } function findFolder(root: FileNode, path: string[]) { const trail: FileNode[] = [root]; let node = root; for (const id of path) { const next = node.children?.find((c) => c.id === id && c.kind === "folder"); if (!next) break; trail.push(next); node = next; } return trail; } function addToFolder(node: FileNode, folderId: string, files: FileNode[]): FileNode { if (node.id === folderId) return { ...node, children: [...(node.children ?? []), ...files] }; if (!node.children) return node; return { ...node, children: node.children.map((c) => addToFolder(c, folderId, files)) }; } const SORT_LABEL: Record = { name: "Name", modified: "Date modified", size: "Size", kind: "Type", }; /** * File browser block: breadcrumb path, grid/list toggle, sort menu, folders * that slide in from the side you travel (deeper from the right, up from the * left), click / Cmd-Ctrl-click / Shift-click range selection, kind icons, * a storage meter, and a drop overlay that adds dragged-in files to the open * folder. */ export function FileManager({ root, path: pathProp, defaultPath = [], onPathChange, view: viewProp, defaultView = "grid", onViewChange, selection: selectionProp, defaultSelection = [], onSelectionChange, defaultSort = { key: "name", direction: "asc" }, onOpenFile, onUpload, storage, locale = "en-US", className, }: FileManagerProps) { const reduce = useReducedMotion(); const [tree, setTree] = useState(root); const [path, setPathState] = useControllable(pathProp, defaultPath, onPathChange); const [view, setView] = useControllable(viewProp, defaultView, onViewChange); const [selection, setSelection] = useControllable( selectionProp, defaultSelection, onSelectionChange, ); const [sort, setSort] = useState(defaultSort); const [dir, setDir] = useState<1 | -1>(1); const [dragging, setDragging] = useState(false); const dragDepth = useRef(0); const anchor = useRef(null); const gridRef = useRef(null); const trail = findFolder(tree, path); const folder = trail[trail.length - 1]; const folderKey = trail.map((n) => n.id).join("/"); const dateFormat = useMemo( () => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }), [locale], ); const items = useMemo(() => { const list = [...(folder.children ?? [])]; const sign = sort.direction === "asc" ? 1 : -1; list.sort((a, b) => { // Folders always first, like every desktop file manager. if ((a.kind === "folder") !== (b.kind === "folder")) return a.kind === "folder" ? -1 : 1; let cmp = 0; if (sort.key === "name") cmp = a.name.localeCompare(b.name, locale); else if (sort.key === "modified") cmp = (a.modified ?? "").localeCompare(b.modified ?? ""); else if (sort.key === "size") cmp = (a.size ?? 0) - (b.size ?? 0); else cmp = a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name, locale); return cmp * sign; }); return list; }, [folder, sort, locale]); const selected = new Set(selection); const navigate = (next: string[]) => { setDir(next.length >= path.length ? 1 : -1); setPathState(next); setSelection([]); anchor.current = null; }; const open = (item: FileNode) => { if (item.kind === "folder") navigate([...trail.slice(1).map((n) => n.id), item.id]); else onOpenFile?.(item, path); }; const select = (item: FileNode, event: MouseEvent | KeyboardEvent) => { const ids = items.map((i) => i.id); if (event.shiftKey && anchor.current && ids.includes(anchor.current)) { const a = ids.indexOf(anchor.current); const b = ids.indexOf(item.id); const range = ids.slice(Math.min(a, b), Math.max(a, b) + 1); const base = event.metaKey || event.ctrlKey ? selection : []; setSelection(Array.from(new Set([...base, ...range]))); return; } anchor.current = item.id; if (event.metaKey || event.ctrlKey) { setSelection( selected.has(item.id) ? selection.filter((id) => id !== item.id) : [...selection, item.id], ); } else setSelection([item.id]); }; const onContainerKey = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { event.preventDefault(); setSelection(items.map((i) => i.id)); } else if (event.key === "Escape") setSelection([]); else if (event.key === "Backspace" && event.altKey && path.length > 0) { navigate(path.slice(0, -1)); } }; const onItemKey = (event: KeyboardEvent, item: FileNode, i: number) => { if (event.key === "Enter") { event.preventDefault(); open(item); return; } if (event.key === " ") { event.preventDefault(); select(item, event); return; } const columns = view === "grid" && gridRef.current ? getComputedStyle(gridRef.current).gridTemplateColumns.split(" ").length : 1; const delta: Record = { ArrowRight: view === "grid" ? 1 : 0, ArrowLeft: view === "grid" ? -1 : 0, ArrowDown: columns, ArrowUp: -columns, }; const move = delta[event.key]; if (!move) return; event.preventDefault(); const target = items[Math.min(items.length - 1, Math.max(0, i + move))]; const buttons = gridRef.current?.querySelectorAll("[data-file-item]"); buttons?.[items.indexOf(target)]?.focus(); if (event.shiftKey) select(target, event); }; // Drop zone: only react to real file drags (not text or links). const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes("Files"); const dropHandlers = { onDragEnter: (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); dragDepth.current += 1; setDragging(true); }, onDragOver: (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }, onDragLeave: (event: DragEvent) => { if (!hasFiles(event)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragging(false); }, onDrop: (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); dragDepth.current = 0; setDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length === 0) return; // "Now" is read inside the event handler, never during render. const stamp = new Date().toISOString(); const nodes: FileNode[] = files.map((f, i) => ({ id: `upload-${stamp}-${i}-${f.name}`, name: f.name, kind: fileKindFromName(f.name), size: f.size, modified: stamp, })); setTree((t) => addToFolder(t, folder.id, nodes)); setSelection(nodes.map((n) => n.id)); onUpload?.(files, folder.id); }, }; const slide = { enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 32 }), center: { opacity: 1, x: 0 }, exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -32 }), }; return (
{/* Toolbar */}
{trail.map((node, i) => { const last = i === trail.length - 1; const target = trail.slice(1, i + 1).map((n) => n.id); return ( {last ? ( {node.name} ) : ( ( )} > {node.name} )} ); })} Sort by setSort({ ...sort, key: key as FileSortKey })} > {(Object.keys(SORT_LABEL) as FileSortKey[]).map((key) => ( {SORT_LABEL[key]} ))} setSort({ ...sort, direction: direction as FileSort["direction"] }) } > Ascending Descending v && setView(v as FileView)} aria-label="View" >
{/* Items */} {/* biome-ignore lint/a11y/noStaticElementInteractions: keyboard shortcuts (select all, clear, up) for the item grid; items are real buttons. */}
{items.length === 0 ? (
This folder is empty — drop files here
) : view === "grid" ? (
{items.map((item, i) => ( select(item, e)} onDoubleClick={() => open(item)} onKeyDown={(e) => onItemKey(e, item, i)} /> ))}
) : (
Name Modified Size
{items.map((item, i) => ( select(item, e)} onDoubleClick={() => open(item)} onKeyDown={(e) => onItemKey(e, item, i)} /> ))}
)}
{/* Footer */}
{selection.length > 0 ? `${selection.length} of ${items.length} selected` : `${items.length} ${items.length === 1 ? "item" : "items"}`} {storage ? (
{formatBytes(storage.used)} / {formatBytes(storage.total)}
) : null}
{/* Drop overlay */} {dragging ? (

Drop to upload to {folder.name}

) : null}
); } function BreadcrumbCrumb({ first, children }: { first: boolean; children: ReactNode }) { return ( <> {first ? null : } {children} ); } function meta(item: FileNode, dateFormat: Intl.DateTimeFormat) { const size = item.kind === "folder" ? `${item.children?.length ?? 0} ${item.children?.length === 1 ? "item" : "items"}` : item.size !== undefined ? formatBytes(item.size) : "—"; const modified = item.modified ? dateFormat.format(new Date(item.modified)) : "—"; return { size, modified }; } function ItemButton({ item, view, selected, meta: info, index, reduce, onClick, onDoubleClick, onKeyDown, }: { item: FileNode; view: FileView; selected: boolean; meta: { size: string; modified: string }; index: number; reduce: boolean; onClick: (event: MouseEvent) => void; onDoubleClick: () => void; onKeyDown: (event: KeyboardEvent) => void; }) { const Icon = KIND_ICON[item.kind]; const tint = KIND_TINT[item.kind]; const label = `${item.name}, ${item.kind === "folder" ? "folder" : item.kind}, ${info.size}`; return ( e.shiftKey && e.preventDefault()} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : Math.min(index, 12) * 0.02 }} className={cn( "group relative rounded-lg text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary", view === "grid" ? "flex flex-col items-center gap-1.5 px-2 pt-3 pb-2" : "grid grid-cols-[1fr_5rem] items-center gap-3 px-2 py-1.5 @md:grid-cols-[1fr_7rem_5rem]", selected ? "bg-primary/10 ring-1 ring-primary/40" : "hover:bg-muted", )} > {view === "grid" ? ( <> {item.name} {info.size} ) : ( <> {item.name} {info.modified} {info.size} )} ); }