"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import {
ArrowDownAZ,
ArrowUpDown,
CheckCircle2,
ChevronRight,
Clock,
Download,
Eye,
FolderInput,
FolderOpen,
FolderPlus,
Info,
LayoutGrid,
List,
Menu,
Pencil,
RotateCcw,
Search,
Star,
StarOff,
Trash2,
Upload as UploadIcon,
Users,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_QUOTA, ME, SEED_ITEMS, SEED_NOW } from "./data";
import { extOf, isTextual, kindFromName, sortItems, splitName } from "./drive-utils";
import { IconButton, Kbd, focusRing } from "./drive-ui";
import { FileViews } from "./file-views";
import { ConfirmDialog, ContextMenu, DropOverlay, MoveDialog, UploadPanel, type MenuEntry } from "./overlays";
import { PreviewPanel } from "./preview-panel";
import { DriveSidebar, NAV } from "./sidebar";
import type { DriveItem, DriveView, Sort, SortKey, Upload } from "./types";
export type { DriveItem, DriveView, FileKind, Upload } from "./types";
export interface FileManagerAppProps {
/** Files and folders. Defaults to a seeded demo drive. */
initialItems?: DriveItem[];
/** Display name of the signed-in user (matches `owner`). */
me?: string;
/** Storage quota in bytes. */
quota?: number;
appName?: string;
initialLayout?: "grid" | "list";
/** Reference time for "Modified" labels. Defaults to the seed time (demo) or the client clock (your data). */
now?: string | Date;
onChange?: (items: DriveItem[]) => void;
/** Called for each file the user uploads, after the (simulated) transfer completes. Hand `file` to your storage API. */
onUpload?: (file: File, item: DriveItem) => void;
onDownload?: (item: DriveItem) => void;
onRename?: (item: DriveItem, name: string) => void;
onMove?: (items: DriveItem[], targetFolderId: string | null) => void;
onTrash?: (items: DriveItem[]) => void;
onDeleteForever?: (items: DriveItem[]) => void;
className?: string;
}
type Toast = { id: number; text: string; undo?: () => void };
type MenuState = { x: number; y: number; entries: MenuEntry[]; label: string } | null;
type Band = { x0: number; y0: number; x1: number; y1: number; base: Set<string> } | null;
const INTERNAL = "application/x-drive-items";
let seq = 0;
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`;
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export function FileManagerApp({
initialItems,
me = ME,
quota = DEFAULT_QUOTA,
appName = "Nimbus Drive",
initialLayout = "grid",
now: nowProp,
onChange,
onUpload,
onDownload,
onRename,
onMove,
onTrash,
onDeleteForever,
className,
}: FileManagerAppProps) {
const [items, setItems] = React.useState<DriveItem[]>(initialItems ?? SEED_ITEMS);
const [now, setNow] = React.useState(() => new Date(nowProp ?? SEED_NOW));
const [view, setView] = React.useState<DriveView>("files");
const [folderId, setFolderId] = React.useState<string | null>(null);
const [layout, setLayout] = React.useState<"grid" | "list">(initialLayout);
const [sort, setSort] = React.useState<Sort>({ key: "name", dir: "asc" });
const [query, setQuery] = React.useState("");
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [anchor, setAnchor] = React.useState<string | null>(null);
const [focusId, setFocusId] = React.useState<string | null>(null);
const [renamingId, setRenamingId] = React.useState<string | null>(null);
const [details, setDetails] = React.useState(false);
const [menu, setMenu] = React.useState<MenuState>(null);
const [moveIds, setMoveIds] = React.useState<string[] | null>(null);
const [confirm, setConfirm] = React.useState<{ kind: "empty" | "delete"; ids: string[] } | null>(null);
const [uploads, setUploads] = React.useState<Upload[]>([]);
const [filesOver, setFilesOver] = React.useState(false);
const [dropHover, setDropHover] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const [drawer, setDrawer] = React.useState(false);
const [band, setBand] = React.useState<Band>(null);
const [bounds, setBounds] = React.useState({ width: 1280, height: 760 });
const [xl, setXl] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const dragImageRef = React.useRef<HTMLDivElement>(null);
const dragIds = React.useRef<string[]>([]);
const dragDepth = React.useRef(0);
const files = React.useRef(new Map<string, File>());
const finalized = React.useRef(new Set<string>());
const objectUrls = React.useRef(new Set<string>());
/* ------------------------------ setup ------------------------------ */
React.useEffect(() => {
if (!nowProp && initialItems) setNow(new Date());
const mq = window.matchMedia("(min-width: 1280px)");
const on = () => {
setXl(mq.matches);
if (mq.matches) setDetails(true);
};
on();
mq.addEventListener("change", on);
const urls = objectUrls.current;
return () => {
mq.removeEventListener("change", on);
urls.forEach((u) => URL.revokeObjectURL(u));
};
}, [nowProp, initialItems]);
React.useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setBounds({ width: el.clientWidth, height: el.clientHeight }));
ro.observe(el);
return () => ro.disconnect();
}, []);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(items);
}, [items]);
/* ------------------------------ derived ------------------------------ */
const byId = React.useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);
const childrenOf = React.useMemo(() => {
const m = new Map<string | null, DriveItem[]>();
for (const i of items) {
const k = i.parentId;
if (!m.has(k)) m.set(k, []);
m.get(k)!.push(i);
}
return m;
}, [items]);
const trashedDeep = React.useCallback(
(i: DriveItem) => {
let cur: DriveItem | undefined = i;
for (let g = 0; cur && g < 50; g++) {
if (cur.trashed) return true;
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
}
return false;
},
[byId],
);
const folderSizes = React.useMemo(() => {
const memo = new Map<string, { size: number; count: number }>();
const walk = (id: string): { size: number; count: number } => {
const hit = memo.get(id);
if (hit) return hit;
let size = 0;
let count = 0;
for (const c of childrenOf.get(id) ?? []) {
if (c.trashed) continue;
count++;
size += c.kind === "folder" ? walk(c.id).size : c.size;
}
const r = { size, count };
memo.set(id, r);
return r;
};
for (const i of items) if (i.kind === "folder") walk(i.id);
return memo;
}, [items, childrenOf]);
const sizeOf = React.useCallback((i: DriveItem) => (i.kind === "folder" ? (folderSizes.get(i.id)?.size ?? 0) : i.size), [folderSizes]);
const pathOf = React.useCallback(
(id: string | null) => {
const out: DriveItem[] = [];
let cur = id ? byId.get(id) : undefined;
for (let g = 0; cur && g < 50; g++) {
out.unshift(cur);
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
}
return out;
},
[byId],
);
const locationOf = React.useCallback((i: DriveItem) => {
const p = pathOf(i.parentId);
return p.length ? p.map((x) => x.name).join(" / ") : i.owner === me ? "My files" : "Shared with me";
}, [pathOf, me]);
const q = query.trim().toLowerCase();
const visible = React.useMemo(() => {
let list: DriveItem[];
if (q) {
list = items.filter((i) => (view === "trash" ? i.trashed : !trashedDeep(i)) && i.name.toLowerCase().includes(q));
} else if (view === "files") {
list = (childrenOf.get(folderId) ?? []).filter((i) => !i.trashed && (folderId !== null || i.owner === me));
} else if (view === "shared") {
list = items.filter((i) => i.owner !== me && !trashedDeep(i) && (!i.parentId || byId.get(i.parentId)?.owner === me));
} else if (view === "recent") {
list = items.filter((i) => i.kind !== "folder" && !trashedDeep(i)).sort((a, b) => b.modified.localeCompare(a.modified)).slice(0, 24);
return list;
} else if (view === "starred") {
list = items.filter((i) => i.starred && !trashedDeep(i));
} else {
list = items.filter((i) => i.trashed);
}
return sortItems(list, sort, sizeOf);
}, [items, q, view, folderId, childrenOf, trashedDeep, sort, sizeOf, byId, me]);
const visibleIds = React.useMemo(() => new Set(visible.map((i) => i.id)), [visible]);
const sel = React.useMemo(() => new Set([...selected].filter((id) => visibleIds.has(id))), [selected, visibleIds]);
const selItems = visible.filter((i) => sel.has(i.id));
const detailsItem = sel.size === 1 ? (byId.get([...sel][0]) ?? null) : null;
const counts = React.useMemo(
() => ({ starred: items.filter((i) => i.starred && !trashedDeep(i)).length, trash: items.filter((i) => i.trashed).length }),
[items, trashedDeep],
);
const storage = React.useMemo(() => {
const b = { docs: 0, media: 0, images: 0, other: 0 };
for (const i of items) {
if (i.kind === "folder" || i.owner !== me) continue;
if (i.kind === "video" || i.kind === "audio") b.media += i.size;
else if (i.kind === "image" || i.kind === "design") b.images += i.size;
else if (["doc", "sheet", "slides", "pdf", "text", "code"].includes(i.kind)) b.docs += i.size;
else b.other += i.size;
}
return {
used: b.docs + b.media + b.images + b.other,
breakdown: [
{ label: "Video & audio", bytes: b.media, color: "bg-rose-500" },
{ label: "Images & design", bytes: b.images, color: "bg-violet-500" },
{ label: "Documents", bytes: b.docs, color: "bg-sky-500" },
{ label: "Other", bytes: b.other, color: "bg-amber-500" },
],
};
}, [items, me]);
/* ------------------------------ toasts ------------------------------ */
const toast = React.useCallback((text: string, undo?: () => void) => {
const id = ++seq;
setToasts((ts) => [...ts.slice(-1), { id, text, undo }]);
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 5000);
}, []);
const dismiss = (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id));
const withUndo = (text: string, change: () => void) => {
const snapshot = items;
change();
toast(text, () => setItems(snapshot));
};
/* ------------------------------ navigation ------------------------------ */
const clearSel = () => {
setSelected(new Set());
setAnchor(null);
};
const goView = (v: DriveView) => {
setView(v);
setFolderId(null);
setQuery("");
clearSel();
setFocusId(null);
setDrawer(false);
setRenamingId(null);
scrollRef.current?.scrollTo({ top: 0 });
};
const openFolder = (id: string | null) => {
setView("files");
setFolderId(id);
setQuery("");
clearSel();
setFocusId(null);
setRenamingId(null);
scrollRef.current?.scrollTo({ top: 0 });
};
const openItem = (i: DriveItem) => {
if (i.trashed || trashedDeep(i)) {
toast("Restore this item to open it");
return;
}
if (i.kind === "folder") return openFolder(i.id);
setSelected(new Set([i.id]));
setAnchor(i.id);
setFocusId(i.id);
setDetails(true);
};
/* ------------------------------ mutations ------------------------------ */
const patch = (ids: string[], fn: (i: DriveItem) => DriveItem) => {
const set = new Set(ids);
setItems((xs) => xs.map((i) => (set.has(i.id) ? fn(i) : i)));
};
const mountedAt = React.useRef(0);
React.useEffect(() => {
mountedAt.current = Date.now();
}, []);
/** Local ISO time for new changes, advancing from the reference "now". */
const stamp = () => {
const d = new Date(now.getTime() + (mountedAt.current ? Date.now() - mountedAt.current : 0));
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const uniqueName = (name: string, parentId: string | null, excludeId?: string) => {
const siblings = new Set((childrenOf.get(parentId) ?? []).filter((s) => s.id !== excludeId && !s.trashed).map((s) => s.name.toLowerCase()));
if (!siblings.has(name.toLowerCase())) return name;
const [base, ext] = splitName(name, !extOf(name));
for (let n = 1; n < 500; n++) {
const c = `${base} (${n})${ext}`;
if (!siblings.has(c.toLowerCase())) return c;
}
return name;
};
const rename = (item: DriveItem, raw: string): string | null => {
const name = raw.trim();
if (!name) return "Name can’t be empty";
if (/[\\/]/.test(name)) return "Names can’t contain / or \\";
if (name === item.name) {
setRenamingId(null);
return null;
}
const clash = (childrenOf.get(item.parentId) ?? []).some((s) => s.id !== item.id && !s.trashed && s.name.toLowerCase() === name.toLowerCase());
if (clash) return "An item with this name already exists";
setRenamingId(null);
withUndo(`Renamed to “${name}”`, () =>
patch([item.id], (i) => ({ ...i, name, kind: i.kind === "folder" ? "folder" : kindFromName(name, i.mime), modified: stamp() })),
);
onRename?.(item, name);
requestAnimationFrame(() => focusItem(item.id));
return null;
};
const star = (ids: string[], value: boolean) => {
patch(ids, (i) => ({ ...i, starred: value }));
const what = ids.length === 1 ? `“${byId.get(ids[0])?.name ?? "item"}”` : plural(ids.length, "item");
toast(value ? `Starred ${what}` : `Removed ${what} from Starred`);
};
const trash = (ids: string[]) => {
const list = ids.map((id) => byId.get(id)).filter(Boolean) as DriveItem[];
withUndo(`Moved ${list.length === 1 ? `“${list[0].name}”` : plural(list.length, "item")} to Trash`, () => patch(ids, (i) => ({ ...i, trashed: true, starred: i.starred })));
clearSel();
onTrash?.(list);
};
const restore = (ids: string[]) => {
withUndo(`Restored ${plural(ids.length, "item")}`, () =>
patch(ids, (i) => {
const parent = i.parentId ? byId.get(i.parentId) : null;
return { ...i, trashed: false, parentId: parent && trashedDeep(parent) ? null : i.parentId };
}),
);
clearSel();
};
const deleteForever = (ids: string[]) => {
const kill = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const i of items) {
if (i.parentId && kill.has(i.parentId) && !kill.has(i.id)) {
kill.add(i.id);
grew = true;
}
}
}
const gone = items.filter((i) => kill.has(i.id));
gone.forEach((i) => {
if (i.url) {
URL.revokeObjectURL(i.url);
objectUrls.current.delete(i.url);
}
});
setItems((xs) => xs.filter((i) => !kill.has(i.id)));
clearSel();
toast(`Deleted ${plural(ids.length, "item")} forever`);
onDeleteForever?.(gone);
};
const isDescendantOrSelf = React.useCallback(
(folder: string | null, ids: string[]) => {
if (folder === null) return false;
const set = new Set(ids);
let cur: string | null = folder;
for (let g = 0; cur && g < 50; g++) {
if (set.has(cur)) return true;
cur = byId.get(cur)?.parentId ?? null;
}
return false;
},
[byId],
);
const move = (ids: string[], target: string | null) => {
if (isDescendantOrSelf(target, ids)) {
toast("A folder can’t be moved into itself");
return;
}
const list = ids.map((id) => byId.get(id)).filter((i): i is DriveItem => Boolean(i) && i!.parentId !== target);
if (!list.length) return;
const tName = target ? (byId.get(target)?.name ?? "folder") : "My files";
withUndo(`Moved ${list.length === 1 ? `“${list[0].name}”` : plural(list.length, "item")} to ${tName}`, () =>
setItems((xs) => xs.map((i) => (list.some((l) => l.id === i.id) ? { ...i, parentId: target, name: uniqueName(i.name, target, i.id), trashed: false } : i))),
);
clearSel();
onMove?.(list, target);
};
const download = (i: DriveItem) => {
onDownload?.(i);
let href: string | null = null;
let revoke = false;
if (i.url) href = i.url;
else if (i.content != null) {
href = URL.createObjectURL(new Blob([i.content], { type: "text/plain" }));
revoke = true;
}
if (href) {
const a = document.createElement("a");
a.href = href;
a.download = i.name;
document.body.appendChild(a);
a.click();
a.remove();
if (revoke) window.setTimeout(() => URL.revokeObjectURL(href!), 1000);
}
toast(`Downloading “${i.name}”`);
};
const newFolder = () => {
const parent = view === "files" && !q ? folderId : null;
if (view !== "files" || q) openFolder(null);
const id = uid("fold");
const item: DriveItem = { id, name: uniqueName("Untitled folder", parent), kind: "folder", parentId: parent, size: 0, modified: stamp(), owner: me };
setItems((xs) => [...xs, item]);
setSelected(new Set([id]));
setAnchor(id);
setFocusId(id);
setRenamingId(id);
};
/* ------------------------------ uploads ------------------------------ */
const startUpload = (list: FileList | File[], parentId: string | null) => {
const arr = Array.from(list).filter((f) => f.size >= 0 && f.name);
if (!arr.length) return;
const ups: Upload[] = arr.map((f) => {
const id = uid("up");
files.current.set(id, f);
return { id, name: f.name, size: Math.max(f.size, 1), progress: 0, status: "uploading", parentId };
});
setUploads((u) => [...u.filter((x) => x.status === "uploading" || u.length < 8), ...ups]);
};
const uploading = uploads.some((u) => u.status === "uploading");
React.useEffect(() => {
if (!uploading) return;
let tick = 0;
const id = window.setInterval(() => {
tick++;
setUploads((us) =>
us.map((u, i) => {
if (u.status !== "uploading") return u;
const speed = Math.min(24, Math.max(2, ((1.6 * 1024 * 1024) / u.size) * 100));
const wobble = [0.6, 1.3, 0.9, 1.1][(tick + i) % 4];
const progress = Math.min(100, u.progress + speed * wobble);
return { ...u, progress, status: progress >= 100 ? "done" : "uploading" };
}),
);
}, 120);
return () => window.clearInterval(id);
}, [uploading]);
// Turn finished uploads into drive items.
React.useEffect(() => {
for (const u of uploads) {
if (u.status !== "done" || finalized.current.has(u.id)) continue;
finalized.current.add(u.id);
const f = files.current.get(u.id);
files.current.delete(u.id);
if (!f) continue;
const kind = kindFromName(f.name, f.type);
const id = uid("file");
let url: string | undefined;
if (kind === "image" || f.type.startsWith("image/")) {
url = URL.createObjectURL(f);
objectUrls.current.add(url);
}
const item: DriveItem = {
id,
name: uniqueName(f.name, u.parentId),
kind,
parentId: u.parentId,
size: f.size,
modified: stamp(),
owner: me,
mime: f.type || undefined,
url,
};
setItems((xs) => [...xs, item]);
onUpload?.(f, item);
if (isTextual(item) && f.size < 256 * 1024) {
f.text()
.then((content) => setItems((xs) => xs.map((x) => (x.id === id ? { ...x, content } : x))))
.catch(() => {});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- react only to upload status changes
}, [uploads]);
/* ------------------------------ selection ------------------------------ */
const focusItem = (id: string) => {
setFocusId(id);
requestAnimationFrame(() => {
const el = scrollRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(id)}"]`);
el?.focus({ preventScroll: true });
el?.scrollIntoView({ block: "nearest" });
});
};
const rangeIds = (a: string, b: string) => {
const ia = visible.findIndex((i) => i.id === a);
const ib = visible.findIndex((i) => i.id === b);
if (ia < 0 || ib < 0) return [b];
const [s, e] = ia < ib ? [ia, ib] : [ib, ia];
return visible.slice(s, e + 1).map((i) => i.id);
};
const onItemClick = (e: React.MouseEvent, item: DriveItem) => {
if (renamingId === item.id) return;
const coarse = window.matchMedia("(pointer: coarse)").matches;
if (coarse && !e.shiftKey && !e.metaKey && !e.ctrlKey && sel.size === 0) {
openItem(item);
return;
}
if (e.shiftKey && anchor) {
const r = rangeIds(anchor, item.id);
setSelected(e.metaKey || e.ctrlKey ? new Set([...sel, ...r]) : new Set(r));
} else if (e.metaKey || e.ctrlKey || (coarse && sel.size > 0)) {
const n = new Set(sel);
if (n.has(item.id)) n.delete(item.id);
else n.add(item.id);
setSelected(n);
setAnchor(item.id);
} else {
setSelected(new Set([item.id]));
setAnchor(item.id);
}
setFocusId(item.id);
};
const toggle = (item: DriveItem) => {
const n = new Set(sel);
if (n.has(item.id)) n.delete(item.id);
else n.add(item.id);
setSelected(n);
setAnchor(item.id);
setFocusId(item.id);
};
/* ------------------------------ context menu ------------------------------ */
const rootPoint = (p: { clientX: number; clientY: number }) => {
const r = rootRef.current!.getBoundingClientRect();
return { x: p.clientX - r.left, y: p.clientY - r.top };
};
const entriesFor = (targets: DriveItem[]): MenuEntry[] => {
const ids = targets.map((t) => t.id);
const one = targets.length === 1 ? targets[0] : null;
if (targets.every((t) => t.trashed)) {
return [
{ label: "Restore", icon: RotateCcw, run: () => restore(ids) },
"sep",
{ label: "Delete forever", icon: Trash2, danger: true, run: () => setConfirm({ kind: "delete", ids }) },
];
}
const allStarred = targets.every((t) => t.starred);
const out: MenuEntry[] = [];
if (one) out.push({ label: one.kind === "folder" ? "Open" : "Preview", icon: one.kind === "folder" ? FolderOpen : Eye, run: () => openItem(one), hint: "↵" });
if (one) out.push({ label: "Rename", icon: Pencil, run: () => setRenamingId(one.id), hint: "F2" });
out.push({ label: allStarred ? "Remove from Starred" : "Add to Starred", icon: allStarred ? StarOff : Star, run: () => star(ids, !allStarred), hint: "S" });
if (targets.some((t) => t.kind !== "folder")) out.push({ label: targets.length > 1 ? `Download ${targets.filter((t) => t.kind !== "folder").length}` : "Download", icon: Download, run: () => targets.filter((t) => t.kind !== "folder").forEach(download) });
out.push({ label: "Move to…", icon: FolderInput, run: () => setMoveIds(ids), hint: "M" });
if (one && (q || view !== "files")) out.push({ label: "Show file location", icon: FolderOpen, run: () => openFolder(one.parentId) });
out.push("sep", { label: "Move to trash", icon: Trash2, danger: true, run: () => trash(ids), hint: "Del" });
return out;
};
const openContext = (e: React.MouseEvent | { clientX: number; clientY: number }, item: DriveItem) => {
let targets: DriveItem[];
if (sel.has(item.id)) targets = selItems;
else {
setSelected(new Set([item.id]));
setAnchor(item.id);
targets = [item];
}
setFocusId(item.id);
const pt = rootPoint(e);
setMenu({ ...pt, entries: entriesFor(targets), label: targets.length === 1 ? `Actions for ${item.name}` : `Actions for ${targets.length} items` });
};
const openSortMenu = (e: React.MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
const pt = rootPoint({ clientX: r.left, clientY: r.bottom + 6 });
const mk = (key: SortKey, label: string): MenuEntry => ({
label: `${label}${sort.key === key ? (sort.dir === "asc" ? " ↑" : " ↓") : ""}`,
icon: key === "name" ? ArrowDownAZ : key === "modified" ? Clock : key === "owner" ? Users : ArrowUpDown,
run: () => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : key === "name" || key === "owner" ? "asc" : "desc" })),
});
setMenu({ ...pt, entries: [mk("name", "Name"), mk("modified", "Last modified"), mk("size", "File size"), mk("owner", "Owner")], label: "Sort by" });
};
/* ------------------------------ drag & drop ------------------------------ */
const dragProps = (item: DriveItem) => ({
draggable: item.owner === me && !item.trashed,
onDragStart: (e: React.DragEvent) => {
const ids = sel.has(item.id) ? [...sel] : [item.id];
if (!sel.has(item.id)) {
setSelected(new Set([item.id]));
setAnchor(item.id);
}
dragIds.current = ids;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(INTERNAL, ids.join(","));
e.dataTransfer.setData("text/plain", ids.map((id) => byId.get(id)?.name ?? "").join("\n"));
const img = dragImageRef.current;
if (img) {
img.textContent = ids.length === 1 ? item.name : `${ids.length} items`;
e.dataTransfer.setDragImage(img, 18, 18);
}
},
onDragEnd: () => {
dragIds.current = [];
setDropHover(null);
},
});
const dropKey = (folderId: string | null) => folderId ?? "__root";
const dropProps = (target: string | null) => ({
onDragOver: (e: React.DragEvent) => {
const types = Array.from(e.dataTransfer.types);
if (types.includes(INTERNAL)) {
if (isDescendantOrSelf(target, dragIds.current) || view === "trash") return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setDropHover(dropKey(target));
} else if (types.includes("Files")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDropHover(dropKey(target));
}
},
onDragLeave: (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropHover((h) => (h === dropKey(target) ? null : h));
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDropHover(null);
setFilesOver(false);
dragDepth.current = 0;
if (Array.from(e.dataTransfer.types).includes(INTERNAL)) {
const ids = e.dataTransfer.getData(INTERNAL).split(",").filter(Boolean);
move(ids.length ? ids : dragIds.current, target);
} else if (e.dataTransfer.files.length) {
startUpload(e.dataTransfer.files, target);
}
dragIds.current = [];
},
});
const uploadTarget = view === "files" && !q ? folderId : null;
const contentDnD = {
onDragEnter: (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
dragDepth.current++;
setFilesOver(true);
},
onDragOver: (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
},
onDragLeave: (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setFilesOver(false);
},
onDrop: (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
dragDepth.current = 0;
setFilesOver(false);
setDropHover(null);
startUpload(e.dataTransfer.files, uploadTarget);
},
};
/* ------------------------------ rubber band ------------------------------ */
const onContentPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || e.pointerType !== "mouse") return;
const t = e.target as HTMLElement;
if (t.closest("[data-item-id],button,input,[role=columnheader],[role=row]")) return;
e.preventDefault();
const sc = scrollRef.current!;
const r = sc.getBoundingClientRect();
const x0 = e.clientX - r.left;
const y0 = e.clientY - r.top + sc.scrollTop;
const base = e.metaKey || e.ctrlKey || e.shiftKey ? new Set(sel) : new Set<string>();
let moved = false;
const move = (ev: PointerEvent) => {
const x1 = ev.clientX - r.left;
const y1 = ev.clientY - r.top + sc.scrollTop;
if (!moved && Math.hypot(x1 - x0, y1 - y0) < 4) return;
moved = true;
if (ev.clientY > r.bottom - 24) sc.scrollTop += 12;
else if (ev.clientY < r.top + 24) sc.scrollTop -= 12;
const L = Math.min(x0, x1) + r.left;
const R = Math.max(x0, x1) + r.left;
const T = Math.min(y0, y1) + r.top - sc.scrollTop;
const B = Math.max(y0, y1) + r.top - sc.scrollTop;
const hits = new Set(base);
sc.querySelectorAll<HTMLElement>("[data-item-id]").forEach((el) => {
const b = el.getBoundingClientRect();
if (b.right > L && b.left < R && b.bottom > T && b.top < B) hits.add(el.dataset.itemId!);
});
setSelected(hits);
setBand({ x0, y0, x1, y1, base });
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
setBand(null);
if (!moved && !(e.metaKey || e.ctrlKey || e.shiftKey)) clearSel();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
/* ------------------------------ keyboard ------------------------------ */
const neighbour = (fromId: string, key: string) => {
const els = Array.from(scrollRef.current?.querySelectorAll<HTMLElement>("[data-item-id]") ?? []);
const cur = els.find((el) => el.dataset.itemId === fromId);
if (!cur) return els[0]?.dataset.itemId ?? null;
if (key === "Home") return els[0]?.dataset.itemId ?? null;
if (key === "End") return els[els.length - 1]?.dataset.itemId ?? null;
const c = cur.getBoundingClientRect();
const cx = c.left + c.width / 2;
const cy = c.top + c.height / 2;
let best: HTMLElement | null = null;
let bestScore = Infinity;
for (const el of els) {
if (el === cur) continue;
const b = el.getBoundingClientRect();
const x = b.left + b.width / 2;
const y = b.top + b.height / 2;
const dx = x - cx;
const dy = y - cy;
const sameRow = Math.abs(dy) < c.height / 2;
const [ok, score] =
key === "ArrowRight"
? [dx > 4 && sameRow, dx]
: key === "ArrowLeft"
? [dx < -4 && sameRow, -dx]
: key === "ArrowDown"
? [dy > 4, dy * 4 + Math.abs(dx)]
: [dy < -4, -dy * 4 + Math.abs(dx)];
if (ok && score < bestScore) {
best = el;
bestScore = score;
}
}
// Wrap left/right across rows in the grid.
if (!best && (key === "ArrowRight" || key === "ArrowLeft")) {
const i = els.indexOf(cur) + (key === "ArrowRight" ? 1 : -1);
best = els[i] ?? null;
}
return best?.dataset.itemId ?? null;
};
const onListKeyDown = (e: React.KeyboardEvent) => {
if ((e.target as HTMLElement).closest("input")) return;
const current = focusId && visibleIds.has(focusId) ? focusId : visible[0]?.id;
if (!current) return;
const item = byId.get(current);
const k = e.key;
const mod = e.metaKey || e.ctrlKey;
if (["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(k)) {
e.preventDefault();
const next = neighbour(current, k);
if (!next) return;
if (e.shiftKey) setSelected(new Set(rangeIds(anchor ?? current, next)));
else if (!mod) {
setSelected(new Set([next]));
setAnchor(next);
}
focusItem(next);
} else if (k === " ") {
e.preventDefault();
if (item) toggle(item);
} else if (k === "Enter") {
e.preventDefault();
if (item) openItem(item);
} else if (k === "Delete" || k === "Backspace") {
e.preventDefault();
const ids = sel.size ? [...sel] : [current];
if (view === "trash") setConfirm({ kind: "delete", ids });
else trash(ids);
} else if (k === "F2") {
e.preventDefault();
if (item && !item.trashed) setRenamingId(item.id);
} else if (mod && k.toLowerCase() === "a") {
e.preventDefault();
setSelected(new Set(visible.map((i) => i.id)));
} else if (k === "Escape" && sel.size) {
e.preventDefault();
e.stopPropagation();
clearSel();
} else if (k === "ContextMenu" || (k === "F10" && e.shiftKey)) {
e.preventDefault();
const el = e.target as HTMLElement;
const r = el.getBoundingClientRect();
if (item) openContext({ clientX: r.left + 24, clientY: r.top + Math.min(r.height, 40) }, item);
} else if (k.toLowerCase() === "s" && !mod && item && !item.trashed) {
e.preventDefault();
const ids = sel.size ? [...sel] : [current];
star(ids, !ids.every((id) => byId.get(id)?.starred));
} else if (k.toLowerCase() === "m" && !mod && item && !item.trashed) {
e.preventDefault();
setMoveIds(sel.size ? [...sel] : [current]);
}
};
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
const t = e.target as HTMLElement;
if (e.key === "Escape") {
if (menu) return setMenu(null);
if (drawer) return setDrawer(false);
if (!xl && details) return setDetails(false);
return;
}
if (moveIds || confirm || e.metaKey || e.ctrlKey || e.altKey || t.closest("input,textarea,select,[contenteditable]")) return;
if (e.key === "/") {
e.preventDefault();
searchRef.current?.focus();
} else if (e.key === "N" && e.shiftKey) {
e.preventDefault();
newFolder();
} else if (e.key === "u" && !t.closest("[role=listbox]")) {
e.preventDefault();
fileInputRef.current?.click();
}
};
});
React.useEffect(() => {
const on = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", on);
return () => window.removeEventListener("keydown", on);
}, []);
/* -------------------------------- render ------------------------------- */
const crumbs = pathOf(folderId);
const viewLabel = NAV.find((n) => n.id === view)?.label ?? "Files";
const headingId = "drive-heading";
const sidebar = (
<DriveSidebar
appName={appName}
view={view}
counts={{ starred: counts.starred, trash: counts.trash }}
used={storage.used}
quota={quota}
breakdown={storage.breakdown}
dropHover={dropHover}
onView={goView}
onNewFolder={() => {
setDrawer(false);
newFolder();
}}
onUpload={() => {
setDrawer(false);
fileInputRef.current?.click();
}}
dropProps={dropProps}
/>
);
const selectionBar = sel.size > 0 && !renamingId;
const inTrash = view === "trash" && !q;
const detailsPanel = (
<PreviewPanel
item={detailsItem}
selectionCount={sel.size}
me={me}
location={detailsItem ? locationOf(detailsItem) : ""}
size={detailsItem ? sizeOf(detailsItem) : 0}
childCount={detailsItem ? (folderSizes.get(detailsItem.id)?.count ?? 0) : 0}
onClose={() => setDetails(false)}
onOpenFolder={(f) => openFolder(f.id)}
onDownload={download}
onStar={(i) => star([i.id], !i.starred)}
onRename={(i) => {
if (!xl) setDetails(false);
setRenamingId(i.id);
}}
onTrash={(i) => (i.trashed ? setConfirm({ kind: "delete", ids: [i.id] }) : trash([i.id]))}
/>
);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-hidden
tabIndex={-1}
onChange={(e) => {
if (e.target.files) startUpload(e.target.files, uploadTarget);
e.target.value = "";
}}
/>
<div ref={dragImageRef} aria-hidden className="pointer-events-none fixed left-[-9999px] top-0 max-w-60 truncate rounded-lg bg-primary px-3 py-2 text-[13px] font-medium text-primary-foreground shadow-xl" />
<div inert={moveIds || confirm || drawer ? true : undefined} className="flex min-w-0 flex-1">
<aside aria-label="Drive navigation" className="hidden w-64 shrink-0 border-r bg-muted/25 lg:block dark:bg-muted/15">
{sidebar}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Top bar */}
<header className="flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<IconButton label="Open navigation" onClick={() => setDrawer(true)} className="lg:hidden">
<Menu className="size-[18px]" />
</IconButton>
<div className="relative max-w-2xl flex-1">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<label htmlFor="drive-search" className="sr-only">
Search in Drive
</label>
<input
id="drive-search"
ref={searchRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
clearSel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setQuery("");
e.currentTarget.blur();
} else if (e.key === "ArrowDown" && visible[0]) {
e.preventDefault();
focusItem(visible[0].id);
}
}}
placeholder={view === "trash" ? "Search in Trash" : "Search in Drive"}
autoComplete="off"
className="h-10 w-full rounded-full border border-transparent bg-muted/70 pl-10 pr-10 text-[13.5px] outline-none transition placeholder:text-muted-foreground focus:border-border focus:bg-background focus:shadow-md dark:bg-muted/60 dark:focus:bg-muted/40"
/>
{query ? (
<button
type="button"
aria-label="Clear search"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
) : (
<span className="pointer-events-none absolute right-3.5 top-1/2 hidden -translate-y-1/2 sm:block">
<Kbd>/</Kbd>
</span>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div role="radiogroup" aria-label="Layout" className="flex rounded-full border p-0.5">
{(
[
["list", List, "List view"],
["grid", LayoutGrid, "Grid view"],
] as const
).map(([id, Icon, label]) => (
<button
key={id}
type="button"
role="radio"
aria-checked={layout === id}
aria-label={label}
title={label}
onClick={() => setLayout(id)}
className={cn("relative grid h-7 w-9 place-items-center rounded-full transition-colors", layout === id ? "text-primary" : "text-muted-foreground hover:text-foreground", focusRing)}
>
{layout === id && <motion.span layoutId="drive-layout-pill" className="absolute inset-0 rounded-full bg-primary/10 dark:bg-primary/20" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<Icon className="relative size-4" />
</button>
))}
</div>
<IconButton label={details ? "Hide details" : "Show details"} aria-pressed={details} onClick={() => setDetails((d) => !d)} className={cn(details && "bg-primary/10 text-primary dark:bg-primary/20")}>
<Info className="size-[18px]" />
</IconButton>
</div>
</header>
<div className="flex min-h-0 flex-1">
<section aria-labelledby={headingId} className="relative flex min-w-0 flex-1 flex-col" {...contentDnD}>
{/* Sub bar: breadcrumbs or selection actions */}
<div className="relative flex h-12 shrink-0 items-center px-3 sm:px-5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<nav aria-label="Breadcrumb" className="min-w-0 flex-1">
<ol id={headingId} className="flex min-w-0 items-center text-[17px] font-semibold tracking-tight sm:text-xl">
{q ? (
<li className="truncate">
Results for “{query.trim()}”
</li>
) : view !== "files" ? (
<li className="truncate">{viewLabel}</li>
) : (
[{ id: null as string | null, name: "My files" }, ...crumbs].map((c, i, arr) => {
const last = i === arr.length - 1;
const collapsed = arr.length > 3 && i > 0 && i < arr.length - 2;
if (collapsed && i > 1) return null;
return (
<li key={c.id ?? "root"} className={cn("flex min-w-0 items-center", last ? "shrink" : "shrink-0")}>
{i > 0 && <ChevronRight className="mx-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />}
{collapsed ? (
<span className="px-1.5 text-muted-foreground">…</span>
) : last ? (
<span aria-current="page" className="truncate px-1.5">
{c.name}
</span>
) : (
<button
type="button"
onClick={() => openFolder(c.id)}
{...dropProps(c.id)}
className={cn(
"max-w-40 truncate rounded-full px-2 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
dropHover === dropKey(c.id) && "bg-primary/15 text-primary ring-2 ring-primary/50",
focusRing,
)}
>
{c.name}
</button>
)}
</li>
);
})
)}
</ol>
</nav>
{inTrash && visible.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "empty", ids: visible.map((i) => i.id) })}
className={cn("h-8 shrink-0 rounded-full px-3 text-[12.5px] font-medium text-destructive transition hover:bg-destructive/10", focusRing)}
>
Empty trash
</button>
)}
{layout === "grid" && view !== "recent" && (
<button
type="button"
onClick={openSortMenu}
aria-haspopup="menu"
className={cn("inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground", focusRing)}
>
<ArrowUpDown className="size-3.5" aria-hidden />
<span className="hidden sm:inline">{{ name: "Name", modified: "Modified", size: "Size", owner: "Owner" }[sort.key]}</span>
</button>
)}
</div>
</div>
{inTrash && visible.length > 0 && (
<p className="mx-3 mb-2 rounded-xl bg-muted/60 px-3.5 py-2 text-[12px] text-muted-foreground sm:mx-5">Items in trash are deleted forever after 30 days.</p>
)}
{/* Content */}
<div ref={scrollRef} onPointerDown={onContentPointerDown} className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${view}-${folderId}-${layout}-${q ? "q" : ""}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4, transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}
className="min-h-full"
>
{visible.length === 0 ? (
<EmptyState view={view} query={q} onUpload={() => fileInputRef.current?.click()} onNewFolder={newFolder} />
) : (
<FileViews
items={visible}
layout={layout}
selected={sel}
focusId={focusId}
renamingId={renamingId}
dropHover={dropHover}
now={now}
me={me}
sort={sort}
onSort={(key) => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : key === "name" || key === "owner" ? "asc" : "desc" }))}
showLocation={Boolean(q) || view !== "files"}
locationOf={locationOf}
sizeOf={sizeOf}
onListKeyDown={onListKeyDown}
labelledBy={headingId}
onItemClick={onItemClick}
onOpen={openItem}
onContext={openContext}
onToggle={toggle}
onToggleAll={(all) => setSelected(all ? new Set(visible.map((i) => i.id)) : new Set())}
onRename={rename}
onRenameCancel={() => {
const id = renamingId;
setRenamingId(null);
if (id) requestAnimationFrame(() => focusItem(id));
}}
dragProps={dragProps}
dropProps={dropProps}
/>
)}
</motion.div>
</AnimatePresence>
{band && (
<div
aria-hidden
className="pointer-events-none absolute z-20 rounded-md border border-primary/60 bg-primary/10"
style={{ left: Math.min(band.x0, band.x1), top: Math.min(band.y0, band.y1), width: Math.abs(band.x1 - band.x0), height: Math.abs(band.y1 - band.y0) }}
/>
)}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-30 flex justify-center px-3">
<AnimatePresence>
{selectionBar && (
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ type: "spring", stiffness: 480, damping: 34 }}
role="toolbar"
aria-label="Selection actions"
className="pointer-events-auto flex h-12 items-center gap-0.5 rounded-full border bg-popover pl-1.5 pr-2 text-popover-foreground shadow-2xl shadow-black/15 dark:shadow-black/60"
>
<IconButton label="Clear selection" onClick={clearSel} className="rounded-full">
<X className="size-4" />
</IconButton>
<span className="mr-2 whitespace-nowrap text-[13px] font-medium tabular-nums" aria-live="polite">
{sel.size} selected
</span>
{inTrash ? (
<>
<IconButton label="Restore" onClick={() => restore([...sel])} className="rounded-full">
<RotateCcw className="size-4" />
</IconButton>
<IconButton label="Delete forever" onClick={() => setConfirm({ kind: "delete", ids: [...sel] })} className="rounded-full hover:text-destructive">
<Trash2 className="size-4" />
</IconButton>
</>
) : (
<>
<IconButton label="Download" onClick={() => selItems.filter((i) => i.kind !== "folder").forEach(download)} disabled={!selItems.some((i) => i.kind !== "folder")} className="rounded-full">
<Download className="size-4" />
</IconButton>
<IconButton label="Move to…" onClick={() => setMoveIds([...sel])} className="rounded-full">
<FolderInput className="size-4" />
</IconButton>
<IconButton
label={selItems.every((i) => i.starred) ? "Remove from Starred" : "Add to Starred"}
onClick={() => star([...sel], !selItems.every((i) => i.starred))}
className="rounded-full"
>
{selItems.every((i) => i.starred) ? <StarOff className="size-4" /> : <Star className="size-4" />}
</IconButton>
<IconButton label="Move to trash" onClick={() => trash([...sel])} className="rounded-full hover:text-destructive">
<Trash2 className="size-4" />
</IconButton>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<DropOverlay show={filesOver && !dropHover} folderName={uploadTarget ? (byId.get(uploadTarget)?.name ?? "this folder") : "My files"} />
</section>
{/* Details — docked on xl */}
<AnimatePresence initial={false}>
{details && xl && (
<motion.aside
aria-label="Details"
initial={{ width: 0, opacity: 0 }}
animate={{ width: 320, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 40 }}
className="shrink-0 overflow-hidden"
>
<div className="mb-3 mr-3 h-[calc(100%-0.75rem)] w-[308px] overflow-hidden rounded-2xl border bg-muted/20 dark:bg-muted/15">{detailsPanel}</div>
</motion.aside>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Details — sheet below xl */}
<AnimatePresence>
{details && !xl && (detailsItem || sel.size > 1) && (
<>
<motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDetails(false)} className="absolute inset-0 z-40 bg-black/25 dark:bg-black/50" />
<motion.aside
aria-label="Details"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 420, damping: 42 }}
className="absolute inset-y-0 right-0 z-50 w-full border-l bg-background shadow-2xl sm:w-96"
>
{detailsPanel}
</motion.aside>
</>
)}
</AnimatePresence>
{/* Mobile nav drawer */}
<AnimatePresence>
{drawer && (
<>
<motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDrawer(false)} className="absolute inset-0 z-40 bg-black/25 lg:hidden dark:bg-black/50" />
<motion.aside
aria-label="Drive navigation"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
className="absolute inset-y-0 left-0 z-50 w-72 max-w-[85%] border-r bg-background shadow-2xl lg:hidden"
>
<IconButton label="Close navigation" onClick={() => setDrawer(false)} className="absolute right-2 top-3 z-10">
<X className="size-4" />
</IconButton>
{sidebar}
</motion.aside>
</>
)}
</AnimatePresence>
{/* Mobile FAB */}
<motion.button
type="button"
whileTap={{ scale: 0.92 }}
onClick={() => fileInputRef.current?.click()}
aria-label="Upload files"
className={cn("absolute bottom-5 right-5 z-30 grid size-14 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-xl shadow-primary/30 lg:hidden", (uploads.length > 0 || selectionBar) && "hidden", focusRing)}
>
<UploadIcon className="size-6" />
</motion.button>
<ContextMenu menu={menu} bounds={bounds} onClose={() => setMenu(null)} />
<MoveDialog
key={moveIds?.join(",") ?? "closed"}
open={Boolean(moveIds)}
items={(moveIds ?? []).map((id) => byId.get(id)).filter((i): i is DriveItem => Boolean(i))}
folders={items.filter((i) => i.kind === "folder" && i.owner === me && !trashedDeep(i))}
moving={moveIds ?? []}
onClose={() => setMoveIds(null)}
onMove={(target) => {
const ids = moveIds ?? [];
setMoveIds(null);
move(ids, target);
}}
/>
<ConfirmDialog
open={Boolean(confirm)}
title={confirm?.kind === "empty" ? "Empty trash?" : `Delete ${confirm && confirm.ids.length > 1 ? plural(confirm.ids.length, "item") : "forever"}?`}
body={
confirm?.kind === "empty"
? `All ${plural(confirm.ids.length, "item")} in Trash will be deleted forever. You can’t undo this.`
: "These items will be deleted forever and you won’t be able to restore them."
}
confirm={confirm?.kind === "empty" ? "Empty trash" : "Delete forever"}
onClose={() => setConfirm(null)}
onConfirm={() => {
const c = confirm;
setConfirm(null);
if (c) deleteForever(c.ids);
}}
/>
<UploadPanel
uploads={uploads}
onCancel={(id) => {
files.current.delete(id);
setUploads((us) => us.map((u) => (u.id === id ? { ...u, status: "cancelled" } : u)));
}}
onClear={() => setUploads((us) => us.filter((u) => u.status === "uploading"))}
/>
{/* Toasts */}
<div aria-live="polite" className={cn("pointer-events-none absolute inset-x-0 z-[90] flex flex-col items-center gap-2 px-4 transition-[bottom] duration-300", selectionBar ? "bottom-20" : "bottom-4")}>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 24, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
role="status"
className="pointer-events-auto relative flex min-w-64 max-w-full items-center gap-2.5 overflow-hidden rounded-xl bg-foreground py-2.5 pl-3 pr-2 text-[13px] text-background shadow-xl shadow-black/20"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-400 dark:text-emerald-600" aria-hidden />
<span className="min-w-0 flex-1 truncate font-medium">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
dismiss(t.id);
}}
className="h-7 rounded-md px-2 text-xs font-semibold text-background/90 outline-none hover:bg-background/10 focus-visible:ring-2 focus-visible:ring-background/60"
>
Undo
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="grid size-6 place-items-center rounded-md text-background/60 outline-none hover:bg-background/10 hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
function EmptyState({ view, query, onUpload, onNewFolder }: { view: DriveView; query: string; onUpload: () => void; onNewFolder: () => void }) {
const copy = query
? { icon: Search, title: "No matching files", body: "Try another name or check the Trash." }
: {
files: { icon: FolderOpen, title: "This folder is empty", body: "Drop files here or use the buttons below." },
shared: { icon: Users, title: "Nothing shared yet", body: "Files people share with you will show up here." },
recent: { icon: Clock, title: "No recent files", body: "Files you open or edit appear here." },
starred: { icon: Star, title: "No starred files", body: "Star things you want to find quickly." },
trash: { icon: Trash2, title: "Trash is empty", body: "Items you delete will wait here for 30 days." },
}[view];
return (
<div className="grid min-h-[420px] place-items-center px-6 pb-16 text-center">
<div>
<motion.span
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="mx-auto grid size-16 place-items-center rounded-3xl bg-gradient-to-br from-sky-100 to-indigo-100 text-primary dark:from-sky-500/15 dark:to-indigo-500/15"
>
<copy.icon className="size-7" />
</motion.span>
<p className="mt-4 text-[15px] font-semibold">{copy.title}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{copy.body}</p>
{view === "files" && !query && (
<div className="mt-5 flex justify-center gap-2">
<button type="button" onClick={onUpload} className={cn("inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-[13px] font-medium text-primary-foreground shadow-sm hover:bg-primary/90", focusRing)}>
<UploadIcon className="size-4" aria-hidden /> Upload
</button>
<button type="button" onClick={onNewFolder} className={cn("inline-flex h-9 items-center gap-1.5 rounded-full border px-4 text-[13px] font-medium hover:bg-accent", focusRing)}>
<FolderPlus className="size-4" aria-hidden /> New folder
</button>
</div>
)}
</div>
</div>
);
}
export default FileManagerApp;