JavaScript 56.7%
TypeScript 42.6%
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