SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
3 h agolast push
JavaScript 56.7% TypeScript 42.6%
42.6 KB · 846 lines tsx
Raw Blame History
1"use client";23import { useState, useEffect, useCallback, useRef, useMemo } from "react";4import { usePathname, useRouter, useSearchParams } from "next/navigation";5import {6  LayoutGrid, List, FolderPlus, Upload, CheckSquare, Users, Search, X, ArrowUpNarrowWide, ArrowDownWideNarrow,7  ArrowUpDown, ChevronDown, Check, FolderOpen, Tag, Loader2, CloudUpload,8} from "lucide-react";9import { motion, AnimatePresence } from "framer-motion";10import toast from "react-hot-toast";11import FileCard from "./FileCard";12import FileUpload, { type FileUploadHandle } from "./FileUpload";13import PreviewEngine from "../preview/PreviewEngine";14import FolderCard from "../folders/FolderCard";15import CreateFolderModal from "../folders/CreateFolderModal";16import CreateSharedFolderModal from "../folders/CreateSharedFolderModal";17import FileActions from "./FileActions";18import Button from "../ui/Button";19import BulkActions from "./BulkActions";20import { EmptyState } from "../ui/PageHeader";21import type { FileData } from "@/types/files";22import { FOLDERS_CHANGED_EVENT, handleInternalDrop, hasInternalDrag, hasNativeFiles, readFileIds, readFolderId } from "./dnd";2324interface FolderData {25  id: string;26  name: string;27  parentId: string | null;28  sharedSpaceId?: string | null;29  sharedSpace?: { id: string; name: string } | null;30  createdAt: string;31  _count?: { files: number; children: number };32}3334/** À la racine, les dossiers de niveau 1 de chaque espace partagé sont regroupés sous le nom de l'espace. */35function groupFoldersBySpace(folders: FolderData[], enabled: boolean): { key: string; label: string | null; folders: FolderData[] }[] {36  if (!enabled) return [{ key: "all", label: null, folders }];37  const groups = new Map<string, { key: string; label: string | null; folders: FolderData[] }>();38  for (const f of folders) {39    const key = f.sharedSpace?.id ?? "__perso";40    if (!groups.has(key)) groups.set(key, { key, label: f.sharedSpace?.name ?? "Mes dossiers", folders: [] });41    groups.get(key)!.folders.push(f);42  }43  const arr = Array.from(groups.values());44  if (arr.length <= 1) return [{ key: "all", label: null, folders }];45  return arr.sort((a, b) => (a.key === "__perso" ? -1 : b.key === "__perso" ? 1 : (a.label ?? "").localeCompare(b.label ?? "", "fr")));46}4748interface FileExplorerProps {49  folderId?: string | null;50  /** Nom du dossier courant (affiché dans la zone d'upload / overlay) */51  folderName?: string;52}5354type SortKey = "name" | "date" | "size" | "type";55type SortDir = "asc" | "desc";56type ViewMode = "grid" | "list";5758const PAGE_LIMIT = 50;59const SORT_LABELS: Record<SortKey, string> = { name: "Nom", date: "Date", size: "Taille", type: "Type" };6061export default function FileExplorer({ folderId, folderName }: FileExplorerProps) {62  const router = useRouter();63  const pathname = usePathname();64  const searchParams = useSearchParams();6566  const [files, setFiles] = useState<FileData[]>([]);67  const [folders, setFolders] = useState<FolderData[]>([]);68  const [total, setTotal] = useState<number | null>(null);69  const [viewMode, setViewModeState] = useState<ViewMode>("list");70  const [sortKey, setSortKey] = useState<SortKey>("name");71  const [sortDir, setSortDir] = useState<SortDir>("asc");72  const [sortOpen, setSortOpen] = useState(false);73  const [showUpload, setShowUpload] = useState(false);74  const [showCreateFolder, setShowCreateFolder] = useState(false);75  const [showCreateSharedFolder, setShowCreateSharedFolder] = useState(false);76  const [previewFile, setPreviewFile] = useState<FileData | null>(null);77  const [searchInput, setSearchInput] = useState("");78  const [searchQuery, setSearchQuery] = useState("");79  const [loading, setLoading] = useState(true);80  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());81  const [selectMode, setSelectMode] = useState(false);82  const [lastClickedId, setLastClickedId] = useState<string | null>(null);83  const [offset, setOffset] = useState(0);84  const [hasMore, setHasMore] = useState(false);85  const [loadingMore, setLoadingMore] = useState(false);86  const [contextMenu, setContextMenu] = useState<{ file: FileData; x: number; y: number } | null>(null);87  const [bgMenu, setBgMenu] = useState<{ x: number; y: number } | null>(null);88  const [nativeDragOver, setNativeDragOver] = useState(false);89  const [tagFilter, setTagFilter] = useState<{ id: string; name?: string; color?: string } | null>(null);9091  const containerRef = useRef<HTMLDivElement>(null);92  const listRef = useRef<HTMLDivElement>(null);93  const uploadRef = useRef<FileUploadHandle>(null);94  const sortRef = useRef<HTMLDivElement>(null);95  const sentinelRef = useRef<HTMLDivElement>(null);96  const nativeDragCount = useRef(0);97  const deepLinkHandled = useRef<string | null>(null);9899  const effectiveSelectMode = selectMode || selectedIds.size > 0;100101  /* ---------- Préférences persistées ---------- */102  const setViewMode = useCallback((v: ViewMode) => {103    setViewModeState(v);104    try { localStorage.setItem("spb-view", v); } catch {}105  }, []);106107  useEffect(() => {108    try {109      const v = localStorage.getItem("spb-view");110      if (v === "grid" || v === "list") setViewModeState(v);111      const s = localStorage.getItem("spb-sort");112      if (s) {113        const { key, dir } = JSON.parse(s);114        if (["name", "date", "size", "type"].includes(key)) setSortKey(key);115        if (dir === "asc" || dir === "desc") setSortDir(dir);116      }117    } catch {}118  }, []);119120  useEffect(() => {121    try { localStorage.setItem("spb-sort", JSON.stringify({ key: sortKey, dir: sortDir })); } catch {}122  }, [sortKey, sortDir]);123124  /* ---------- Recherche (debounce) ---------- */125  useEffect(() => {126    const t = setTimeout(() => setSearchQuery(searchInput.trim()), 220);127    return () => clearTimeout(t);128  }, [searchInput]);129130  /* ---------- Filtre par tag (?tagId=) ---------- */131  const tagIdParam = searchParams.get("tagId");132  useEffect(() => {133    if (!tagIdParam) { setTagFilter(null); return; }134    setTagFilter({ id: tagIdParam });135    fetch("/api/tags").then((r) => (r.ok ? r.json() : [])).then((tags) => {136      const t = Array.isArray(tags) ? tags.find((x: { id: string }) => x.id === tagIdParam) : null;137      if (t) setTagFilter({ id: t.id, name: t.name, color: t.color });138    }).catch(() => {});139  }, [tagIdParam]);140141  /* ---------- Chargement ---------- */142  const fetchData = useCallback(143    async (loadMore = false) => {144      if (loadMore) setLoadingMore(true);145      else { setLoading(true); setOffset(0); }146      try {147        const currentOffset = loadMore ? offset : 0;148        const params = new URLSearchParams();149        if (tagIdParam) params.set("tagId", tagIdParam);150        else params.set("folderId", folderId || "root");151        if (searchQuery) params.set("search", searchQuery);152        params.set("limit", String(PAGE_LIMIT));153        params.set("offset", String(currentOffset));154155        const [filesRes, foldersRes] = await Promise.all([156          fetch(`/api/files?${params}`),157          loadMore || tagIdParam ? Promise.resolve(null) : fetch(`/api/folders?parentId=${folderId || "root"}`),158        ]);159160        if (filesRes.ok) {161          const data = await filesRes.json();162          const newFiles: FileData[] = Array.isArray(data) ? data : data.files || [];163          const tot: number | undefined = Array.isArray(data) ? undefined : data.total;164          setFiles((prev) => (loadMore ? [...prev, ...newFiles] : newFiles));165          setTotal(tot ?? null);166          setHasMore(tot !== undefined ? currentOffset + PAGE_LIMIT < tot : newFiles.length >= PAGE_LIMIT);167          setOffset(currentOffset + newFiles.length);168        }169        if (tagIdParam && !loadMore) setFolders([]);170        if (foldersRes && foldersRes.ok) setFolders(await foldersRes.json());171      } catch (err) {172        console.error("Failed to fetch data:", err);173        toast.error("Impossible de charger le dossier");174      } finally {175        setLoading(false);176        setLoadingMore(false);177      }178    },179    [folderId, searchQuery, offset, tagIdParam]180  );181182  useEffect(() => {183    fetchData();184    // eslint-disable-next-line react-hooks/exhaustive-deps185  }, [folderId, searchQuery, tagIdParam]);186187  const handleRefresh = useCallback(() => {188    fetchData(false);189    // eslint-disable-next-line react-hooks/exhaustive-deps190  }, [folderId, searchQuery, tagIdParam]);191192  // Rafraîchir quand un dépôt externe (arbre latéral) a déplacé des fichiers193  useEffect(() => {194    const h = () => handleRefresh();195    window.addEventListener("spb:files-changed", h);196    window.addEventListener(FOLDERS_CHANGED_EVENT, h);197    return () => {198      window.removeEventListener("spb:files-changed", h);199      window.removeEventListener(FOLDERS_CHANGED_EVENT, h);200    };201  }, [handleRefresh]);202203  // Purger la sélection des fichiers disparus204  useEffect(() => {205    setSelectedIds((prev) => {206      if (prev.size === 0) return prev;207      const ids = new Set(files.map((f) => f.id));208      const next = new Set(Array.from(prev).filter((id) => ids.has(id)));209      return next.size === prev.size ? prev : next;210    });211  }, [files]);212213  // Chargement infini214  useEffect(() => {215    const el = sentinelRef.current;216    if (!el || !hasMore || loading) return;217    const io = new IntersectionObserver((entries) => {218      if (entries[0]?.isIntersecting && !loadingMore) fetchData(true);219    }, { rootMargin: "400px" });220    io.observe(el);221    return () => io.disconnect();222  }, [hasMore, loading, loadingMore, fetchData]);223224  /* ---------- Deep links : ?file= ?upload=1 ?new=1 ---------- */225  useEffect(() => {226    const fileParam = searchParams.get("file");227    const uploadParam = searchParams.get("upload");228    const newParam = searchParams.get("new");229    if (!fileParam && !uploadParam && !newParam) { deepLinkHandled.current = null; return; }230    const key = `${fileParam}|${uploadParam}|${newParam}`;231    if (deepLinkHandled.current === key) return;232    if (fileParam && loading) return; // attendre la liste pour retrouver le fichier233    deepLinkHandled.current = key;234235    if (uploadParam) setShowUpload(true);236    if (newParam) setShowCreateFolder(true);237    if (fileParam) {238      const found = files.find((f) => f.id === fileParam);239      if (found) setPreviewFile(found);240      else {241        fetch(`/api/files/${fileParam}`)242          .then((r) => (r.ok ? r.json() : null))243          .then((f) => { if (f && f.id) setPreviewFile(f as FileData); else toast.error("Fichier introuvable"); })244          .catch(() => toast.error("Fichier introuvable"));245      }246    }247    // Nettoyer l'URL (conserver le filtre tag)248    const keep = new URLSearchParams();249    if (tagIdParam) keep.set("tagId", tagIdParam);250    const qs = keep.toString();251    router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });252  }, [searchParams, files, loading, router, pathname, tagIdParam]);253254  /* ---------- Tri ---------- */255  const dirMul = sortDir === "asc" ? 1 : -1;256  const sortedFolders = useMemo(() => [...folders].sort((a, b) => dirMul * a.name.localeCompare(b.name, "fr", { numeric: true })), [folders, dirMul]);257  const folderGroups = useMemo(() => groupFoldersBySpace(sortedFolders, !folderId), [sortedFolders, folderId]);258  const sortedFiles = useMemo(() => [...files].sort((a, b) => {259    let r = 0;260    switch (sortKey) {261      case "date": r = new Date(a.updatedAt || a.createdAt).getTime() - new Date(b.updatedAt || b.createdAt).getTime(); break;262      case "size": r = (a.size || 0) - (b.size || 0); break;263      case "type": r = (a.mimeType || "").localeCompare(b.mimeType || "") || a.name.localeCompare(b.name, "fr", { numeric: true }); break;264      default: r = a.name.localeCompare(b.name, "fr", { numeric: true });265    }266    return dirMul * r;267  }), [files, sortKey, dirMul]);268269  const toggleSortColumn = (key: SortKey) => {270    if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));271    else { setSortKey(key); setSortDir(key === "date" || key === "size" ? "desc" : "asc"); }272  };273274  useEffect(() => {275    if (!sortOpen) return;276    const h = (e: MouseEvent) => { if (sortRef.current && !sortRef.current.contains(e.target as Node)) setSortOpen(false); };277    document.addEventListener("mousedown", h);278    return () => document.removeEventListener("mousedown", h);279  }, [sortOpen]);280281  /* ---------- Sélection ---------- */282  const toggleSelect = useCallback((file: FileData, e?: { shiftKey?: boolean }) => {283    setSelectedIds((prev) => {284      const next = new Set(prev);285      if (e?.shiftKey && lastClickedId) {286        const ids = sortedFiles.map((f) => f.id);287        const a = ids.indexOf(lastClickedId);288        const b = ids.indexOf(file.id);289        if (a >= 0 && b >= 0) {290          const [lo, hi] = a < b ? [a, b] : [b, a];291          for (let i = lo; i <= hi; i++) next.add(ids[i]);292          return next;293        }294      }295      next.has(file.id) ? next.delete(file.id) : next.add(file.id);296      return next;297    });298    setLastClickedId(file.id);299  }, [lastClickedId, sortedFiles]);300301  const selectAll = useCallback(() => {302    setSelectedIds((prev) => (prev.size === files.length ? new Set() : new Set(files.map((f) => f.id))));303  }, [files]);304305  const clearSelection = useCallback(() => { setSelectedIds(new Set()); setSelectMode(false); }, []);306307  /* ---------- Raccourcis ---------- */308  useEffect(() => {309    const handleKeyDown = (e: KeyboardEvent) => {310      const target = e.target as HTMLElement;311      if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;312      if (e.key === "Delete" || e.key === "Backspace") {313        if (selectedIds.size > 0) {314          e.preventDefault();315          (document.querySelector("[data-bulk-delete]") as HTMLButtonElement | null)?.click();316        }317      }318      if ((e.ctrlKey || e.metaKey) && e.key === "a") {319        if (files.length > 0) { e.preventDefault(); setSelectMode(true); selectAll(); }320      }321      if (e.key === "Escape") {322        if (contextMenu || bgMenu) { setContextMenu(null); setBgMenu(null); }323        else if (previewFile) setPreviewFile(null);324        else if (selectedIds.size > 0 || selectMode) clearSelection();325        else if (showUpload) setShowUpload(false);326      }327      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "n") { e.preventDefault(); setShowCreateFolder(true); }328      if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === "u" && !previewFile) { e.preventDefault(); setShowUpload(true); }329    };330    window.addEventListener("keydown", handleKeyDown);331    return () => window.removeEventListener("keydown", handleKeyDown);332  }, [selectMode, selectedIds, files, previewFile, contextMenu, bgMenu, showUpload, selectAll, clearSelection]);333334  /* ---------- Navigation clavier (roving) ---------- */335  const onListKeyDown = (e: React.KeyboardEvent) => {336    if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;337    const t = e.target as HTMLElement;338    if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return;339    const items = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);340    if (!items.length) return;341    const cur = items.findIndex((el) => el === document.activeElement || el.contains(document.activeElement));342    let next = cur;343    let cols = 1;344    if (viewMode === "grid" && cur >= 0) {345      const top = items[cur].offsetTop;346      cols = items.filter((el) => el.offsetTop === top && el.parentElement === items[cur].parentElement).length || 1;347    }348    switch (e.key) {349      case "ArrowDown": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + cols); break;350      case "ArrowUp": next = cur < 0 ? 0 : Math.max(0, cur - cols); break;351      case "ArrowRight": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + 1); break;352      case "ArrowLeft": next = cur < 0 ? 0 : Math.max(0, cur - 1); break;353      case "Home": next = 0; break;354      case "End": next = items.length - 1; break;355    }356    if (next !== cur && items[next]) {357      e.preventDefault();358      items[next].focus();359      items[next].scrollIntoView({ block: "nearest" });360    }361  };362363  /* ---------- Dépôt natif (fichiers du bureau) : overlay plein écran ---------- */364  useEffect(() => {365    const onEnter = (e: DragEvent) => {366      if (!hasNativeFiles(e.dataTransfer)) return;367      e.preventDefault();368      nativeDragCount.current++;369      setNativeDragOver(true);370    };371    const onOver = (e: DragEvent) => {372      if (!hasNativeFiles(e.dataTransfer)) return;373      e.preventDefault();374      if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";375    };376    const onLeave = (e: DragEvent) => {377      if (!hasNativeFiles(e.dataTransfer)) return;378      nativeDragCount.current = Math.max(0, nativeDragCount.current - 1);379      if (nativeDragCount.current === 0) setNativeDragOver(false);380    };381    const onDrop = (e: DragEvent) => {382      if (!hasNativeFiles(e.dataTransfer)) return;383      e.preventDefault();384      nativeDragCount.current = 0;385      setNativeDragOver(false);386      const dropped = Array.from(e.dataTransfer?.files ?? []);387      if (!dropped.length) return;388      setShowUpload(true);389      // Laisser FileUpload se monter/afficher puis injecter390      requestAnimationFrame(() => uploadRef.current?.addFiles(dropped));391      toast.success(dropped.length > 1 ? `${dropped.length} fichiers ajoutés à l'envoi` : "Fichier ajouté à l'envoi");392    };393    const onEnd = () => { nativeDragCount.current = 0; setNativeDragOver(false); };394    window.addEventListener("dragenter", onEnter);395    window.addEventListener("dragover", onOver);396    window.addEventListener("dragleave", onLeave);397    window.addEventListener("drop", onDrop);398    window.addEventListener("dragend", onEnd);399    return () => {400      window.removeEventListener("dragenter", onEnter);401      window.removeEventListener("dragover", onOver);402      window.removeEventListener("dragleave", onLeave);403      window.removeEventListener("drop", onDrop);404      window.removeEventListener("dragend", onEnd);405    };406  }, []);407408  /* ---------- Dépôt interne sur les miettes de la TopBar (délégation) ---------- */409  useEffect(() => {410    const crumbOf = (t: EventTarget | null) => (t instanceof Element ? t.closest<HTMLElement>("[data-crumb-root],[data-crumb-id]") : null);411    const onOver = (e: DragEvent) => {412      const el = crumbOf(e.target);413      if (!el || !hasInternalDrag(e.dataTransfer)) return;414      e.preventDefault();415      if (e.dataTransfer) e.dataTransfer.dropEffect = "move";416      el.classList.add("drop-target");417    };418    const onLeave = (e: DragEvent) => {419      const el = crumbOf(e.target);420      if (el) el.classList.remove("drop-target");421    };422    const onDrop = async (e: DragEvent) => {423      const el = crumbOf(e.target);424      if (!el || !hasInternalDrag(e.dataTransfer)) return;425      e.preventDefault();426      e.stopPropagation();427      el.classList.remove("drop-target");428      const target = el.dataset.crumbId || "root";429      if (target === (folderId || "root")) return;430      const moved = await handleInternalDrop(e.dataTransfer, target);431      if (moved) handleRefresh();432    };433    document.addEventListener("dragover", onOver);434    document.addEventListener("dragleave", onLeave);435    document.addEventListener("drop", onDrop);436    return () => {437      document.removeEventListener("dragover", onOver);438      document.removeEventListener("dragleave", onLeave);439      document.removeEventListener("drop", onDrop);440    };441  }, [folderId, handleRefresh]);442443  // Dépôt interne « vers la racine du dossier courant » sur la zone vide de l'explorateur444  const onContentDrop = async (e: React.DragEvent) => {445    if (!hasInternalDrag(e.dataTransfer)) return;446    e.preventDefault();447    const here = new Set(files.map((f) => f.id));448    const ids = readFileIds(e.dataTransfer);449    if (ids.length && ids.every((id) => here.has(id))) return; // déjà dans ce dossier450    if (readFolderId(e.dataTransfer) && folders.some((f) => f.id === readFolderId(e.dataTransfer))) return;451    const moved = await handleInternalDrop(e.dataTransfer, folderId || "root");452    if (moved) handleRefresh();453  };454455  /* ---------- Menus contextuels ---------- */456  const openContextMenu = (file: FileData, e: React.MouseEvent) => {457    setBgMenu(null);458    setContextMenu({ file, x: e.clientX, y: e.clientY });459  };460  const onBackgroundContext = (e: React.MouseEvent) => {461    if ((e.target as HTMLElement).closest('[role="option"]')) return;462    e.preventDefault();463    setContextMenu(null);464    setBgMenu({ x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 120) });465  };466  useEffect(() => {467    if (!bgMenu) return;468    const h = () => setBgMenu(null);469    document.addEventListener("mousedown", h);470    return () => document.removeEventListener("mousedown", h);471  }, [bgMenu]);472473  const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]);474  const isSearch = searchQuery.length > 0;475  const currentName = folderName || (folderId ? "ce dossier" : "Fichiers");476477  /* ---------- Squelettes ---------- */478  const Skeleton = () => (479    <div className="space-y-2" aria-busy>480      {viewMode === "grid" ? (481        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-3">482          {Array.from({ length: 10 }).map((_, i) => (483            <div key={i} className="card overflow-hidden animate-pulse">484              <div className="aspect-square bg-gray-100 dark:bg-white/5" />485              <div className="p-2.5 space-y-1.5"><div className="h-3 bg-gray-200 dark:bg-white/10 rounded w-3/4" /><div className="h-2 bg-gray-200 dark:bg-white/10 rounded w-1/2" /></div>486            </div>487          ))}488        </div>489      ) : (490        <div className="card overflow-hidden divide-y divide-line dark:divide-white/5">491          {Array.from({ length: 8 }).map((_, i) => (492            <div key={i} className="flex items-center gap-3 px-3 py-3 animate-pulse">493              <div className="w-8 h-8 bg-gray-200 dark:bg-white/10 rounded-lg shrink-0" />494              <div className="flex-1 h-3 bg-gray-200 dark:bg-white/10 rounded" style={{ maxWidth: `${35 + ((i * 17) % 40)}%` }} />495              <div className="w-16 h-3 bg-gray-200 dark:bg-white/10 rounded hidden sm:block" />496              <div className="w-20 h-3 bg-gray-200 dark:bg-white/10 rounded hidden md:block" />497            </div>498          ))}499        </div>500      )}501    </div>502  );503504  const SortIndicator = ({ k }: { k: SortKey }) =>505    sortKey === k ? (506      sortDir === "asc" ? <ArrowUpNarrowWide className="w-3.5 h-3.5 text-blue-500" /> : <ArrowDownWideNarrow className="w-3.5 h-3.5 text-blue-500" />507    ) : <ArrowUpDown className="w-3 h-3 opacity-0 group-hover/th:opacity-60" />;508509  const ListHeader = () => {510    const allSelected = files.length > 0 && selectedIds.size === files.length;511    const Th = ({ k, className, children }: { k: SortKey; className: string; children: React.ReactNode }) => (512      <button onClick={() => toggleSortColumn(k)} className={`group/th flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-200 transition-colors ${className}`}>513        {children}<SortIndicator k={k} />514      </button>515    );516    return (517      <div className="sticky top-0 z-10 hidden sm:flex items-center gap-3 px-3 h-9 bg-surface/95 backdrop-blur border-b border-line dark:border-white/[0.07] text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 select-none">518        <span className="w-8 shrink-0 flex items-center justify-center">519          {files.length > 0 && (520            <button521              onClick={selectAll}522              aria-label={allSelected ? "Tout désélectionner" : "Tout sélectionner"}523              className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${allSelected ? "bg-blue-500 border-blue-500 text-white" : selectedIds.size > 0 ? "bg-blue-500/40 border-blue-500 text-white" : "border-gray-300 dark:border-white/25 text-transparent hover:border-blue-400"}`}524            >525              <Check className="w-3 h-3" strokeWidth={3} />526            </button>527          )}528        </span>529        <Th k="name" className="flex-1 min-w-0 text-left">Nom</Th>530        <Th k="type" className="hidden lg:flex w-24 shrink-0">Type</Th>531        <Th k="size" className="w-24 shrink-0 justify-end">Taille</Th>532        <Th k="date" className="hidden md:flex w-28 shrink-0 justify-end">Modifié</Th>533        <span className="w-10 shrink-0" />534      </div>535    );536  };537538  return (539    <div className="flex flex-col h-full lg:flex-row overflow-x-hidden" ref={containerRef}>540      <div className="flex-1 flex flex-col min-w-0 overflow-x-hidden">541        {/* ===== Barre d'outils ===== */}542        <div className="px-3 sm:px-4 lg:px-6 pt-3 pb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 shrink-0">543          {/* Recherche */}544          <div className="relative flex-1 min-w-0">545            <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />546            <input547              type="search"548              value={searchInput}549              onChange={(e) => setSearchInput(e.target.value)}550              placeholder={`Rechercher dans ${currentName}…`}551              className="field pl-10 pr-9 h-10"552              aria-label="Rechercher des fichiers"553            />554            {searchInput && (555              <button onClick={() => setSearchInput("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Effacer">556                <X className="w-4 h-4" />557              </button>558            )}559          </div>560561          <div className="flex items-center gap-1.5 sm:gap-2 overflow-x-auto scrollbar-none -mx-3 px-3 sm:mx-0 sm:px-0 sm:overflow-visible">562            {/* Tri */}563            <div className="relative shrink-0" ref={sortRef}>564              <button565                onClick={() => setSortOpen((o) => !o)}566                className="h-10 px-3 flex items-center gap-1.5 rounded-xl bg-gray-100 dark:bg-white/[0.06] hover:bg-gray-200/70 dark:hover:bg-white/10 text-[13px] text-gray-700 dark:text-gray-200 transition-colors"567                aria-haspopup="menu"568                aria-expanded={sortOpen}569              >570                {sortDir === "asc" ? <ArrowUpNarrowWide className="w-4 h-4 text-gray-500" /> : <ArrowDownWideNarrow className="w-4 h-4 text-gray-500" />}571                <span className="hidden md:inline">{SORT_LABELS[sortKey]}</span>572                <ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${sortOpen ? "rotate-180" : ""}`} />573              </button>574              <AnimatePresence>575                {sortOpen && (576                  <motion.div577                    initial={{ opacity: 0, y: -4, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -4, scale: 0.98 }} transition={{ duration: 0.14 }}578                    className="absolute right-0 sm:left-0 sm:right-auto top-full mt-1.5 w-52 glass rounded-2xl shadow-pop p-1.5 z-40"579                    role="menu"580                  >581                    <p className="px-3 pt-1.5 pb-1 text-[10.5px] font-semibold uppercase tracking-wider text-gray-400">Trier par</p>582                    {(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (583                      <button key={k} role="menuitemradio" aria-checked={sortKey === k} onClick={() => { setSortKey(k); }} className={`w-full flex items-center gap-2 px-3 h-9 rounded-xl text-[13px] transition-colors ${sortKey === k ? "bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"}`}>584                        <span className="flex-1 text-left">{SORT_LABELS[k]}</span>585                        {sortKey === k && <Check className="w-4 h-4" />}586                      </button>587                    ))}588                    <div className="my-1 border-t border-line dark:border-white/10" />589                    <div className="grid grid-cols-2 gap-1 p-0.5">590                      {(["asc", "desc"] as SortDir[]).map((d) => (591                        <button key={d} onClick={() => setSortDir(d)} className={`h-8 rounded-lg text-[12px] flex items-center justify-center gap-1 transition-colors ${sortDir === d ? "bg-gray-900 text-white dark:bg-white dark:text-gray-900" : "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10"}`}>592                          {d === "asc" ? <ArrowUpNarrowWide className="w-3.5 h-3.5" /> : <ArrowDownWideNarrow className="w-3.5 h-3.5" />}593                          {d === "asc" ? "Croissant" : "Décroissant"}594                        </button>595                      ))}596                    </div>597                  </motion.div>598                )}599              </AnimatePresence>600            </div>601602            {/* Vue */}603            <div className="relative flex items-center h-10 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] shrink-0" role="radiogroup" aria-label="Affichage">604              {(["list", "grid"] as ViewMode[]).map((v) => (605                <button606                  key={v}607                  role="radio"608                  aria-checked={viewMode === v}609                  onClick={() => setViewMode(v)}610                  className={`relative w-9 h-8 flex items-center justify-center rounded-lg transition-colors ${viewMode === v ? "text-gray-900 dark:text-white" : "text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"}`}611                  title={v === "list" ? "Liste" : "Grille"}612                >613                  {viewMode === v && <motion.span layoutId="view-seg" className="absolute inset-0 rounded-lg bg-surface dark:bg-white/10 shadow-card dark:shadow-none" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}614                  <span className="relative">{v === "list" ? <List className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}</span>615                </button>616              ))}617            </div>618619            <div className="hidden sm:block w-px h-6 bg-line dark:bg-white/10 mx-0.5 shrink-0" />620621            <Button variant={effectiveSelectMode ? "secondary" : "ghost"} size="sm" onClick={() => (effectiveSelectMode ? clearSelection() : setSelectMode(true))} className="shrink-0 h-10" title="Mode sélection">622              <CheckSquare className="w-4 h-4" /><span className="hidden lg:inline">Sélection</span>623            </Button>624            <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)} className="shrink-0 h-10" title="Nouveau dossier (⌘⇧N)">625              <FolderPlus className="w-4 h-4" /><span className="hidden lg:inline">Dossier</span>626            </Button>627            <Button variant="ghost" size="sm" onClick={() => setShowCreateSharedFolder(true)} className="shrink-0 h-10 text-blue-600 dark:text-blue-300 hover:text-blue-700" title="Espace partagé">628              <Users className="w-4 h-4" /><span className="hidden lg:inline">Partage</span>629            </Button>630            <Button variant="accent" size="sm" onClick={() => setShowUpload((s) => !s)} className="shrink-0 h-10" title="Envoyer des fichiers (U)">631              <Upload className="w-4 h-4" /><span className="hidden sm:inline">Envoyer</span>632            </Button>633          </div>634        </div>635636        {/* Sous-titre : compteurs + filtre tag */}637        <div className="px-3 sm:px-4 lg:px-6 pb-2 flex items-center gap-2 text-[12px] text-gray-400 min-h-[24px] shrink-0">638          {!loading && (639            <span className="tabular-nums">640              {tagIdParam ? null : <>{sortedFolders.length} dossier{sortedFolders.length !== 1 ? "s" : ""} · </>}641              {total ?? files.length} fichier{(total ?? files.length) !== 1 ? "s" : ""}642              {isSearch && <> pour « {searchQuery} »</>}643            </span>644          )}645          {tagFilter && (646            <button647              onClick={() => router.replace(pathname)}648              className="flex items-center gap-1.5 pl-2 pr-1.5 h-6 rounded-full bg-gray-100 dark:bg-white/[0.07] text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors"649              title="Retirer le filtre"650            >651              <Tag className="w-3 h-3 text-gray-400" />652              {tagFilter.color && <span className="w-2 h-2 rounded-full" style={{ backgroundColor: tagFilter.color }} />}653              <span className="font-medium">{tagFilter.name || "Tag"}</span>654              <X className="w-3 h-3" />655            </button>656          )}657          {selectedIds.size > 0 && <span className="ml-auto text-blue-600 dark:text-blue-300 font-medium tabular-nums">{selectedIds.size} sélectionné{selectedIds.size > 1 ? "s" : ""}</span>}658        </div>659660        {/* Zone d'upload (montée en permanence pour la file d'attente) */}661        <div className={`px-3 sm:px-4 lg:px-6 shrink-0 ${showUpload ? "pb-3" : ""}`}>662          <FileUpload ref={uploadRef} folderId={folderId} onUploadComplete={handleRefresh} showZone={showUpload} folderName={folderName} />663        </div>664665        {/* ===== Contenu ===== */}666        <div667          ref={listRef}668          className="flex-1 overflow-auto overflow-x-hidden px-3 sm:px-4 lg:px-6 pb-6 outline-none"669          onKeyDown={onListKeyDown}670          onContextMenu={onBackgroundContext}671          onDragOver={(e) => { if (hasInternalDrag(e.dataTransfer) && !(e.target as HTMLElement).closest("[data-folder-id]")) e.preventDefault(); }}672          onDrop={onContentDrop}673          role="listbox"674          aria-multiselectable675          aria-label="Contenu du dossier"676        >677          {loading ? (678            <Skeleton />679          ) : folders.length === 0 && files.length === 0 ? (680            isSearch ? (681              <EmptyState icon={Search} title="Aucun résultat" description={`Aucun fichier ne correspond à « ${searchQuery} » dans ${currentName}.`} action={<Button variant="secondary" size="sm" onClick={() => setSearchInput("")}>Effacer la recherche</Button>} />682            ) : tagIdParam ? (683              <EmptyState icon={Tag} title="Aucun fichier avec ce tag" action={<Button variant="secondary" size="sm" onClick={() => router.replace(pathname)}>Retirer le filtre</Button>} />684            ) : (685              <EmptyState686                icon={FolderOpen}687                title="Ce dossier est vide"688                description="Glissez des fichiers ici ou utilisez le bouton Envoyer. Vous pouvez aussi créer un sous-dossier."689                action={690                  <div className="flex items-center gap-2">691                    <Button variant="secondary" size="sm" onClick={() => setShowCreateFolder(true)}><FolderPlus className="w-4 h-4" /> Nouveau dossier</Button>692                    <Button variant="accent" size="sm" onClick={() => { setShowUpload(true); uploadRef.current?.openPicker(); }}><Upload className="w-4 h-4" /> Envoyer</Button>693                  </div>694                }695              />696            )697          ) : viewMode === "list" ? (698            <div className="card overflow-clip">699              <ListHeader />700              <div className="divide-y divide-line/70 dark:divide-white/5">701                {folderGroups.map((g) => (702                  <div key={g.key} className="divide-y divide-line/70 dark:divide-white/5">703                    {g.label && (704                      <div className="flex items-center gap-2 px-4 py-2 bg-gray-50/80 dark:bg-white/[0.03] text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">705                        <Users className="w-3.5 h-3.5 text-blue-500" /> {g.label} <span className="text-gray-400 font-normal normal-case tracking-normal">· {g.folders.length} dossier{g.folders.length > 1 ? "s" : ""}</span>706                      </div>707                    )}708                    {g.folders.map((folder) => (709                      <FolderCard key={folder.id} folder={folder} onRefresh={handleRefresh} viewMode="list" focusable />710                    ))}711                  </div>712                ))}713                {sortedFiles.map((file) => (714                  <FileCard715                    key={file.id}716                    file={file}717                    onPreview={setPreviewFile}718                    onRefresh={handleRefresh}719                    viewMode="list"720                    selected={selectedIds.has(file.id)}721                    selectMode={effectiveSelectMode}722                    onToggleSelect={toggleSelect}723                    dragIds={selectedArr}724                    onContextMenu={openContextMenu}725                    focusable726                  />727                ))}728              </div>729            </div>730          ) : (731            <div className="space-y-6">732              {folderGroups.map((g) => g.folders.length > 0 && (733                <section key={g.key}>734                  <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5 flex items-center gap-2">735                    {g.label ? <><Users className="w-3.5 h-3.5 text-blue-500" /> {g.label}</> : "Dossiers"}736                  </h3>737                  <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5">738                    {g.folders.map((folder) => (739                      <FolderCard key={folder.id} folder={folder} onRefresh={handleRefresh} viewMode="grid" focusable />740                    ))}741                  </div>742                </section>743              ))}744              {sortedFiles.length > 0 && (745                <section>746                  <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5">Fichiers</h3>747                  <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8">748                    {sortedFiles.map((file) => (749                      <FileCard750                        key={file.id}751                        file={file}752                        onPreview={setPreviewFile}753                        onRefresh={handleRefresh}754                        viewMode="grid"755                        selected={selectedIds.has(file.id)}756                        selectMode={effectiveSelectMode}757                        onToggleSelect={toggleSelect}758                        dragIds={selectedArr}759                        onContextMenu={openContextMenu}760                        focusable761                      />762                    ))}763                  </div>764                </section>765              )}766            </div>767          )}768769          {/* Chargement infini */}770          {hasMore && !loading && (771            <div ref={sentinelRef} className="mt-6 flex justify-center">772              <button onClick={() => fetchData(true)} disabled={loadingMore} className="px-5 h-10 text-[13px] font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/[0.06] hover:bg-gray-200 dark:hover:bg-white/10 rounded-xl transition-colors disabled:opacity-60 flex items-center gap-2">773                {loadingMore ? <><Loader2 className="w-4 h-4 animate-spin" /> Chargement…</> : `Charger plus (${Math.max(0, (total ?? 0) - files.length)} restants)`}774              </button>775            </div>776          )}777        </div>778      </div>779780      {/* ===== Aperçu (panneau latéral desktop / plein écran mobile) ===== */}781      {previewFile && (782        <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:z-auto lg:w-[420px] xl:w-[460px] lg:shrink-0 lg:border-l lg:border-line dark:lg:border-white/[0.07]">783          <PreviewEngine file={previewFile} files={sortedFiles} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} onChanged={handleRefresh} />784        </div>785      )}786787      {/* ===== Barre d'actions groupées ===== */}788      <BulkActions selectedIds={selectedArr} totalCount={files.length} onClear={clearSelection} onRefresh={handleRefresh} onSelectAll={selectAll} currentFolderId={folderId || "root"} />789790      {/* ===== Menu contextuel fichier ===== */}791      {contextMenu && (792        <FileActions793          key={contextMenu.file.id}794          file={contextMenu.file}795          anchor={{ x: contextMenu.x, y: contextMenu.y }}796          onClose={() => setContextMenu(null)}797          onRefresh={handleRefresh}798          onPreview={() => setPreviewFile(contextMenu.file)}799        />800      )}801802      {/* ===== Menu contextuel fond ===== */}803      <AnimatePresence>804        {bgMenu && (805          <motion.div806            initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.96 }} transition={{ duration: 0.12 }}807            className="fixed z-[75] w-52 glass rounded-2xl shadow-pop p-1.5" style={{ left: bgMenu.x, top: bgMenu.y }}808            onMouseDown={(e) => e.stopPropagation()} role="menu"809          >810            {[811              { icon: FolderPlus, label: "Nouveau dossier", hint: "⌘⇧N", run: () => setShowCreateFolder(true) },812              { icon: Upload, label: "Envoyer des fichiers", hint: "U", run: () => { setShowUpload(true); uploadRef.current?.openPicker(); } },813              { icon: CheckSquare, label: "Tout sélectionner", hint: "⌘A", run: () => { setSelectMode(true); selectAll(); } },814            ].map((it) => (815              <button key={it.label} role="menuitem" onClick={() => { it.run(); setBgMenu(null); }} className="w-full flex items-center gap-2.5 px-3 h-9 rounded-xl text-[13px] text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors">816                <it.icon className="w-4 h-4 text-gray-400" /><span className="flex-1 text-left">{it.label}</span><kbd>{it.hint}</kbd>817              </button>818            ))}819          </motion.div>820        )}821      </AnimatePresence>822823      {/* ===== Overlay de dépôt natif ===== */}824      <AnimatePresence>825        {nativeDragOver && (826          <motion.div827            initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}828            className="fixed inset-0 z-[65] flex items-center justify-center bg-canvas/70 dark:bg-canvas/80 backdrop-blur-sm pointer-events-none"829          >830            <motion.div initial={{ scale: 0.94, y: 8 }} animate={{ scale: 1, y: 0 }} className="mx-6 w-full max-w-md rounded-3xl border-2 border-dashed border-blue-500 bg-surface/90 dark:bg-elevated/90 shadow-glow p-10 text-center">831              <div className="mx-auto mb-4 w-16 h-16 rounded-3xl bg-accent-gradient text-white flex items-center justify-center shadow-glow animate-float">832                <CloudUpload className="w-8 h-8" />833              </div>834              <p className="text-lg font-semibold text-gray-900 dark:text-white">Déposer pour envoyer</p>835              <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">dans <span className="font-medium text-gray-800 dark:text-gray-100">{currentName}</span></p>836            </motion.div>837          </motion.div>838        )}839      </AnimatePresence>840841      <CreateFolderModal isOpen={showCreateFolder} onClose={() => setShowCreateFolder(false)} parentId={folderId || null} onCreated={handleRefresh} />842      <CreateSharedFolderModal isOpen={showCreateSharedFolder} onClose={() => setShowCreateSharedFolder(false)} onCreated={handleRefresh} />843    </div>844  );845}846