import { useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react"; import { useFiles } from "@/store/files"; import { client } from "@/jmap/client"; import type { FileNode } from "@/jmap/types"; import { formatSize, formatListDate } from "@/lib/format"; import { Empty, Spinner } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; export function FilesView({ nodeId }: { nodeId?: string }) { const [, navigate] = useLocation(); const files = useFiles(); const parentId = nodeId ?? null; const [dropping, setDropping] = useState(false); const [selected, setSelected] = useState(null); const menu = useMenu(); const [menuNode, setMenuNode] = useState(null); const [moveNode, setMoveNode] = useState(null); const inputRef = useRef(null); useEffect(() => { if (files.available) void files.loadChildren(parentId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [files.available, parentId]); // Ensure ancestors are loaded for breadcrumbs useEffect(() => { if (!files.available || !parentId) return; const n = files.nodes[parentId]; if (!n) { void client.call<{ list: FileNode[] }>("FileNode/get", { accountId: files.accountId, ids: [parentId], fetchParents: true }).then((r) => { useFiles.setState((s) => { const nodes = { ...s.nodes }; for (const x of r.list) nodes[x.id] = x; return { nodes }; }); }).catch(() => undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentId, files.available]); if (!files.available) return
} title="File storage is not available">This account does not have the JMAP file storage capability.
; const ids = files.children[parentId ?? "root"] ?? []; const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n)); const path = files.pathTo(parentId); const onDrop = (e: React.DragEvent) => { e.preventDefault(); setDropping(false); const list = Array.from(e.dataTransfer.files); if (list.length) void files.upload(parentId, list); }; const download = (n: FileNode) => { if (!n.blobId) return; const a = document.createElement("a"); a.href = client.downloadUrl(files.accountId!, n.blobId, n.name, n.type ?? "application/octet-stream"); a.download = n.name; a.click(); }; return (
{ if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
{path.map((n, i) => ( ))}
{ const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
{files.uploads.length > 0 && (
{files.uploads.map((u) =>
{u.name}{u.error ? {u.error} : {u.progress}%}
)}
)} {files.error &&
{files.error}
}
{files.loading && !nodes.length ? : !nodes.length ? ( } title="This folder is empty">Drag files here or use Upload. ) : ( {nodes.map((n) => ( setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}> ))}
NameSizeModified
{n.nodeType === "directory" ? : } { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}
{n.nodeType === "directory" ? "—" : formatSize(n.size)} {formatListDate(n.modified ?? n.created)}
)}
{menuNode && ( <> {menuNode.nodeType === "directory" ? } label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : } label="Download" onClick={() => download(menuNode)} />} } label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> } label="Move to…" onClick={() => setMoveNode(menuNode)} /> } label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} /> )} {moveNode && setMoveNode(null)} />}
); } function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) { const files = useFiles(); const [cur, setCur] = useState(null); useEffect(() => { void files.loadChildren(cur); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cur]); const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id)); const path = files.pathTo(cur); return ( }>
{path.map((n) => )}
{dirs.map((d) => )} {!dirs.length &&

No subfolders here.

}
); }