Fazekit

Code

"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;

More in Productivity

View all →