"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { LayoutGrid, List, FolderPlus, Upload, CheckSquare, Users, Search, X, ArrowUpNarrowWide, ArrowDownWideNarrow, ArrowUpDown, ChevronDown, Check, FolderOpen, Tag, Loader2, CloudUpload, } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import toast from "react-hot-toast"; import FileCard from "./FileCard"; import FileUpload, { type FileUploadHandle } from "./FileUpload"; import PreviewEngine from "../preview/PreviewEngine"; import FolderCard from "../folders/FolderCard"; import CreateFolderModal from "../folders/CreateFolderModal"; import CreateSharedFolderModal from "../folders/CreateSharedFolderModal"; import FileActions from "./FileActions"; import Button from "../ui/Button"; import BulkActions from "./BulkActions"; import { EmptyState } from "../ui/PageHeader"; import type { FileData } from "@/types/files"; import { FOLDERS_CHANGED_EVENT, handleInternalDrop, hasInternalDrag, hasNativeFiles, readFileIds, readFolderId } from "./dnd"; interface FolderData { id: string; name: string; parentId: string | null; sharedSpaceId?: string | null; sharedSpace?: { id: string; name: string } | null; createdAt: string; _count?: { files: number; children: number }; } /** À la racine, les dossiers de niveau 1 de chaque espace partagé sont regroupés sous le nom de l'espace. */ function groupFoldersBySpace(folders: FolderData[], enabled: boolean): { key: string; label: string | null; folders: FolderData[] }[] { if (!enabled) return [{ key: "all", label: null, folders }]; const groups = new Map(); for (const f of folders) { const key = f.sharedSpace?.id ?? "__perso"; if (!groups.has(key)) groups.set(key, { key, label: f.sharedSpace?.name ?? "Mes dossiers", folders: [] }); groups.get(key)!.folders.push(f); } const arr = Array.from(groups.values()); if (arr.length <= 1) return [{ key: "all", label: null, folders }]; return arr.sort((a, b) => (a.key === "__perso" ? -1 : b.key === "__perso" ? 1 : (a.label ?? "").localeCompare(b.label ?? "", "fr"))); } interface FileExplorerProps { folderId?: string | null; /** Nom du dossier courant (affiché dans la zone d'upload / overlay) */ folderName?: string; } type SortKey = "name" | "date" | "size" | "type"; type SortDir = "asc" | "desc"; type ViewMode = "grid" | "list"; const PAGE_LIMIT = 50; const SORT_LABELS: Record = { name: "Nom", date: "Date", size: "Taille", type: "Type" }; export default function FileExplorer({ folderId, folderName }: FileExplorerProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const [files, setFiles] = useState([]); const [folders, setFolders] = useState([]); const [total, setTotal] = useState(null); const [viewMode, setViewModeState] = useState("list"); const [sortKey, setSortKey] = useState("name"); const [sortDir, setSortDir] = useState("asc"); const [sortOpen, setSortOpen] = useState(false); const [showUpload, setShowUpload] = useState(false); const [showCreateFolder, setShowCreateFolder] = useState(false); const [showCreateSharedFolder, setShowCreateSharedFolder] = useState(false); const [previewFile, setPreviewFile] = useState(null); const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState(true); const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectMode, setSelectMode] = useState(false); const [lastClickedId, setLastClickedId] = useState(null); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [contextMenu, setContextMenu] = useState<{ file: FileData; x: number; y: number } | null>(null); const [bgMenu, setBgMenu] = useState<{ x: number; y: number } | null>(null); const [nativeDragOver, setNativeDragOver] = useState(false); const [tagFilter, setTagFilter] = useState<{ id: string; name?: string; color?: string } | null>(null); const containerRef = useRef(null); const listRef = useRef(null); const uploadRef = useRef(null); const sortRef = useRef(null); const sentinelRef = useRef(null); const nativeDragCount = useRef(0); const deepLinkHandled = useRef(null); const effectiveSelectMode = selectMode || selectedIds.size > 0; /* ---------- Préférences persistées ---------- */ const setViewMode = useCallback((v: ViewMode) => { setViewModeState(v); try { localStorage.setItem("spb-view", v); } catch {} }, []); useEffect(() => { try { const v = localStorage.getItem("spb-view"); if (v === "grid" || v === "list") setViewModeState(v); const s = localStorage.getItem("spb-sort"); if (s) { const { key, dir } = JSON.parse(s); if (["name", "date", "size", "type"].includes(key)) setSortKey(key); if (dir === "asc" || dir === "desc") setSortDir(dir); } } catch {} }, []); useEffect(() => { try { localStorage.setItem("spb-sort", JSON.stringify({ key: sortKey, dir: sortDir })); } catch {} }, [sortKey, sortDir]); /* ---------- Recherche (debounce) ---------- */ useEffect(() => { const t = setTimeout(() => setSearchQuery(searchInput.trim()), 220); return () => clearTimeout(t); }, [searchInput]); /* ---------- Filtre par tag (?tagId=) ---------- */ const tagIdParam = searchParams.get("tagId"); useEffect(() => { if (!tagIdParam) { setTagFilter(null); return; } setTagFilter({ id: tagIdParam }); fetch("/api/tags").then((r) => (r.ok ? r.json() : [])).then((tags) => { const t = Array.isArray(tags) ? tags.find((x: { id: string }) => x.id === tagIdParam) : null; if (t) setTagFilter({ id: t.id, name: t.name, color: t.color }); }).catch(() => {}); }, [tagIdParam]); /* ---------- Chargement ---------- */ const fetchData = useCallback( async (loadMore = false) => { if (loadMore) setLoadingMore(true); else { setLoading(true); setOffset(0); } try { const currentOffset = loadMore ? offset : 0; const params = new URLSearchParams(); if (tagIdParam) params.set("tagId", tagIdParam); else params.set("folderId", folderId || "root"); if (searchQuery) params.set("search", searchQuery); params.set("limit", String(PAGE_LIMIT)); params.set("offset", String(currentOffset)); const [filesRes, foldersRes] = await Promise.all([ fetch(`/api/files?${params}`), loadMore || tagIdParam ? Promise.resolve(null) : fetch(`/api/folders?parentId=${folderId || "root"}`), ]); if (filesRes.ok) { const data = await filesRes.json(); const newFiles: FileData[] = Array.isArray(data) ? data : data.files || []; const tot: number | undefined = Array.isArray(data) ? undefined : data.total; setFiles((prev) => (loadMore ? [...prev, ...newFiles] : newFiles)); setTotal(tot ?? null); setHasMore(tot !== undefined ? currentOffset + PAGE_LIMIT < tot : newFiles.length >= PAGE_LIMIT); setOffset(currentOffset + newFiles.length); } if (tagIdParam && !loadMore) setFolders([]); if (foldersRes && foldersRes.ok) setFolders(await foldersRes.json()); } catch (err) { console.error("Failed to fetch data:", err); toast.error("Impossible de charger le dossier"); } finally { setLoading(false); setLoadingMore(false); } }, [folderId, searchQuery, offset, tagIdParam] ); useEffect(() => { fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [folderId, searchQuery, tagIdParam]); const handleRefresh = useCallback(() => { fetchData(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [folderId, searchQuery, tagIdParam]); // Rafraîchir quand un dépôt externe (arbre latéral) a déplacé des fichiers useEffect(() => { const h = () => handleRefresh(); window.addEventListener("spb:files-changed", h); window.addEventListener(FOLDERS_CHANGED_EVENT, h); return () => { window.removeEventListener("spb:files-changed", h); window.removeEventListener(FOLDERS_CHANGED_EVENT, h); }; }, [handleRefresh]); // Purger la sélection des fichiers disparus useEffect(() => { setSelectedIds((prev) => { if (prev.size === 0) return prev; const ids = new Set(files.map((f) => f.id)); const next = new Set(Array.from(prev).filter((id) => ids.has(id))); return next.size === prev.size ? prev : next; }); }, [files]); // Chargement infini useEffect(() => { const el = sentinelRef.current; if (!el || !hasMore || loading) return; const io = new IntersectionObserver((entries) => { if (entries[0]?.isIntersecting && !loadingMore) fetchData(true); }, { rootMargin: "400px" }); io.observe(el); return () => io.disconnect(); }, [hasMore, loading, loadingMore, fetchData]); /* ---------- Deep links : ?file= ?upload=1 ?new=1 ---------- */ useEffect(() => { const fileParam = searchParams.get("file"); const uploadParam = searchParams.get("upload"); const newParam = searchParams.get("new"); if (!fileParam && !uploadParam && !newParam) { deepLinkHandled.current = null; return; } const key = `${fileParam}|${uploadParam}|${newParam}`; if (deepLinkHandled.current === key) return; if (fileParam && loading) return; // attendre la liste pour retrouver le fichier deepLinkHandled.current = key; if (uploadParam) setShowUpload(true); if (newParam) setShowCreateFolder(true); if (fileParam) { const found = files.find((f) => f.id === fileParam); if (found) setPreviewFile(found); else { fetch(`/api/files/${fileParam}`) .then((r) => (r.ok ? r.json() : null)) .then((f) => { if (f && f.id) setPreviewFile(f as FileData); else toast.error("Fichier introuvable"); }) .catch(() => toast.error("Fichier introuvable")); } } // Nettoyer l'URL (conserver le filtre tag) const keep = new URLSearchParams(); if (tagIdParam) keep.set("tagId", tagIdParam); const qs = keep.toString(); router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); }, [searchParams, files, loading, router, pathname, tagIdParam]); /* ---------- Tri ---------- */ const dirMul = sortDir === "asc" ? 1 : -1; const sortedFolders = useMemo(() => [...folders].sort((a, b) => dirMul * a.name.localeCompare(b.name, "fr", { numeric: true })), [folders, dirMul]); const folderGroups = useMemo(() => groupFoldersBySpace(sortedFolders, !folderId), [sortedFolders, folderId]); const sortedFiles = useMemo(() => [...files].sort((a, b) => { let r = 0; switch (sortKey) { case "date": r = new Date(a.updatedAt || a.createdAt).getTime() - new Date(b.updatedAt || b.createdAt).getTime(); break; case "size": r = (a.size || 0) - (b.size || 0); break; case "type": r = (a.mimeType || "").localeCompare(b.mimeType || "") || a.name.localeCompare(b.name, "fr", { numeric: true }); break; default: r = a.name.localeCompare(b.name, "fr", { numeric: true }); } return dirMul * r; }), [files, sortKey, dirMul]); const toggleSortColumn = (key: SortKey) => { if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); else { setSortKey(key); setSortDir(key === "date" || key === "size" ? "desc" : "asc"); } }; useEffect(() => { if (!sortOpen) return; const h = (e: MouseEvent) => { if (sortRef.current && !sortRef.current.contains(e.target as Node)) setSortOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, [sortOpen]); /* ---------- Sélection ---------- */ const toggleSelect = useCallback((file: FileData, e?: { shiftKey?: boolean }) => { setSelectedIds((prev) => { const next = new Set(prev); if (e?.shiftKey && lastClickedId) { const ids = sortedFiles.map((f) => f.id); const a = ids.indexOf(lastClickedId); const b = ids.indexOf(file.id); if (a >= 0 && b >= 0) { const [lo, hi] = a < b ? [a, b] : [b, a]; for (let i = lo; i <= hi; i++) next.add(ids[i]); return next; } } next.has(file.id) ? next.delete(file.id) : next.add(file.id); return next; }); setLastClickedId(file.id); }, [lastClickedId, sortedFiles]); const selectAll = useCallback(() => { setSelectedIds((prev) => (prev.size === files.length ? new Set() : new Set(files.map((f) => f.id)))); }, [files]); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setSelectMode(false); }, []); /* ---------- Raccourcis ---------- */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return; if (e.key === "Delete" || e.key === "Backspace") { if (selectedIds.size > 0) { e.preventDefault(); (document.querySelector("[data-bulk-delete]") as HTMLButtonElement | null)?.click(); } } if ((e.ctrlKey || e.metaKey) && e.key === "a") { if (files.length > 0) { e.preventDefault(); setSelectMode(true); selectAll(); } } if (e.key === "Escape") { if (contextMenu || bgMenu) { setContextMenu(null); setBgMenu(null); } else if (previewFile) setPreviewFile(null); else if (selectedIds.size > 0 || selectMode) clearSelection(); else if (showUpload) setShowUpload(false); } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "n") { e.preventDefault(); setShowCreateFolder(true); } if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === "u" && !previewFile) { e.preventDefault(); setShowUpload(true); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectMode, selectedIds, files, previewFile, contextMenu, bgMenu, showUpload, selectAll, clearSelection]); /* ---------- Navigation clavier (roving) ---------- */ const onListKeyDown = (e: React.KeyboardEvent) => { if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return; const t = e.target as HTMLElement; if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return; const items = Array.from(listRef.current?.querySelectorAll('[role="option"]') ?? []); if (!items.length) return; const cur = items.findIndex((el) => el === document.activeElement || el.contains(document.activeElement)); let next = cur; let cols = 1; if (viewMode === "grid" && cur >= 0) { const top = items[cur].offsetTop; cols = items.filter((el) => el.offsetTop === top && el.parentElement === items[cur].parentElement).length || 1; } switch (e.key) { case "ArrowDown": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + cols); break; case "ArrowUp": next = cur < 0 ? 0 : Math.max(0, cur - cols); break; case "ArrowRight": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + 1); break; case "ArrowLeft": next = cur < 0 ? 0 : Math.max(0, cur - 1); break; case "Home": next = 0; break; case "End": next = items.length - 1; break; } if (next !== cur && items[next]) { e.preventDefault(); items[next].focus(); items[next].scrollIntoView({ block: "nearest" }); } }; /* ---------- Dépôt natif (fichiers du bureau) : overlay plein écran ---------- */ useEffect(() => { const onEnter = (e: DragEvent) => { if (!hasNativeFiles(e.dataTransfer)) return; e.preventDefault(); nativeDragCount.current++; setNativeDragOver(true); }; const onOver = (e: DragEvent) => { if (!hasNativeFiles(e.dataTransfer)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; }; const onLeave = (e: DragEvent) => { if (!hasNativeFiles(e.dataTransfer)) return; nativeDragCount.current = Math.max(0, nativeDragCount.current - 1); if (nativeDragCount.current === 0) setNativeDragOver(false); }; const onDrop = (e: DragEvent) => { if (!hasNativeFiles(e.dataTransfer)) return; e.preventDefault(); nativeDragCount.current = 0; setNativeDragOver(false); const dropped = Array.from(e.dataTransfer?.files ?? []); if (!dropped.length) return; setShowUpload(true); // Laisser FileUpload se monter/afficher puis injecter requestAnimationFrame(() => uploadRef.current?.addFiles(dropped)); toast.success(dropped.length > 1 ? `${dropped.length} fichiers ajoutés à l'envoi` : "Fichier ajouté à l'envoi"); }; const onEnd = () => { nativeDragCount.current = 0; setNativeDragOver(false); }; window.addEventListener("dragenter", onEnter); window.addEventListener("dragover", onOver); window.addEventListener("dragleave", onLeave); window.addEventListener("drop", onDrop); window.addEventListener("dragend", onEnd); return () => { window.removeEventListener("dragenter", onEnter); window.removeEventListener("dragover", onOver); window.removeEventListener("dragleave", onLeave); window.removeEventListener("drop", onDrop); window.removeEventListener("dragend", onEnd); }; }, []); /* ---------- Dépôt interne sur les miettes de la TopBar (délégation) ---------- */ useEffect(() => { const crumbOf = (t: EventTarget | null) => (t instanceof Element ? t.closest("[data-crumb-root],[data-crumb-id]") : null); const onOver = (e: DragEvent) => { const el = crumbOf(e.target); if (!el || !hasInternalDrag(e.dataTransfer)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; el.classList.add("drop-target"); }; const onLeave = (e: DragEvent) => { const el = crumbOf(e.target); if (el) el.classList.remove("drop-target"); }; const onDrop = async (e: DragEvent) => { const el = crumbOf(e.target); if (!el || !hasInternalDrag(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); el.classList.remove("drop-target"); const target = el.dataset.crumbId || "root"; if (target === (folderId || "root")) return; const moved = await handleInternalDrop(e.dataTransfer, target); if (moved) handleRefresh(); }; document.addEventListener("dragover", onOver); document.addEventListener("dragleave", onLeave); document.addEventListener("drop", onDrop); return () => { document.removeEventListener("dragover", onOver); document.removeEventListener("dragleave", onLeave); document.removeEventListener("drop", onDrop); }; }, [folderId, handleRefresh]); // Dépôt interne « vers la racine du dossier courant » sur la zone vide de l'explorateur const onContentDrop = async (e: React.DragEvent) => { if (!hasInternalDrag(e.dataTransfer)) return; e.preventDefault(); const here = new Set(files.map((f) => f.id)); const ids = readFileIds(e.dataTransfer); if (ids.length && ids.every((id) => here.has(id))) return; // déjà dans ce dossier if (readFolderId(e.dataTransfer) && folders.some((f) => f.id === readFolderId(e.dataTransfer))) return; const moved = await handleInternalDrop(e.dataTransfer, folderId || "root"); if (moved) handleRefresh(); }; /* ---------- Menus contextuels ---------- */ const openContextMenu = (file: FileData, e: React.MouseEvent) => { setBgMenu(null); setContextMenu({ file, x: e.clientX, y: e.clientY }); }; const onBackgroundContext = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('[role="option"]')) return; e.preventDefault(); setContextMenu(null); setBgMenu({ x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 120) }); }; useEffect(() => { if (!bgMenu) return; const h = () => setBgMenu(null); document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, [bgMenu]); const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]); const isSearch = searchQuery.length > 0; const currentName = folderName || (folderId ? "ce dossier" : "Fichiers"); /* ---------- Squelettes ---------- */ const Skeleton = () => (
{viewMode === "grid" ? (
{Array.from({ length: 10 }).map((_, i) => (
))}
) : (
{Array.from({ length: 8 }).map((_, i) => (
))}
)}
); const SortIndicator = ({ k }: { k: SortKey }) => sortKey === k ? ( sortDir === "asc" ? : ) : ; const ListHeader = () => { const allSelected = files.length > 0 && selectedIds.size === files.length; const Th = ({ k, className, children }: { k: SortKey; className: string; children: React.ReactNode }) => ( ); return (
{files.length > 0 && ( )} Nom Type Taille Modifié
); }; return (
{/* ===== Barre d'outils ===== */}
{/* Recherche */}
setSearchInput(e.target.value)} placeholder={`Rechercher dans ${currentName}…`} className="field pl-10 pr-9 h-10" aria-label="Rechercher des fichiers" /> {searchInput && ( )}
{/* Tri */}
{sortOpen && (

Trier par

{(Object.keys(SORT_LABELS) as SortKey[]).map((k) => ( ))}
{(["asc", "desc"] as SortDir[]).map((d) => ( ))}
)}
{/* Vue */}
{(["list", "grid"] as ViewMode[]).map((v) => ( ))}
{/* Sous-titre : compteurs + filtre tag */}
{!loading && ( {tagIdParam ? null : <>{sortedFolders.length} dossier{sortedFolders.length !== 1 ? "s" : ""} · } {total ?? files.length} fichier{(total ?? files.length) !== 1 ? "s" : ""} {isSearch && <> pour « {searchQuery} »} )} {tagFilter && ( )} {selectedIds.size > 0 && {selectedIds.size} sélectionné{selectedIds.size > 1 ? "s" : ""}}
{/* Zone d'upload (montée en permanence pour la file d'attente) */}
{/* ===== Contenu ===== */}
{ if (hasInternalDrag(e.dataTransfer) && !(e.target as HTMLElement).closest("[data-folder-id]")) e.preventDefault(); }} onDrop={onContentDrop} role="listbox" aria-multiselectable aria-label="Contenu du dossier" > {loading ? ( ) : folders.length === 0 && files.length === 0 ? ( isSearch ? ( setSearchInput("")}>Effacer la recherche} /> ) : tagIdParam ? ( router.replace(pathname)}>Retirer le filtre} /> ) : (
} /> ) ) : viewMode === "list" ? (
{folderGroups.map((g) => (
{g.label && (
{g.label} · {g.folders.length} dossier{g.folders.length > 1 ? "s" : ""}
)} {g.folders.map((folder) => ( ))}
))} {sortedFiles.map((file) => ( ))}
) : (
{folderGroups.map((g) => g.folders.length > 0 && (

{g.label ? <> {g.label} : "Dossiers"}

{g.folders.map((folder) => ( ))}
))} {sortedFiles.length > 0 && (

Fichiers

{sortedFiles.map((file) => ( ))}
)}
)} {/* Chargement infini */} {hasMore && !loading && (
)}
{/* ===== Aperçu (panneau latéral desktop / plein écran mobile) ===== */} {previewFile && (
setPreviewFile(null)} onNavigate={setPreviewFile} onChanged={handleRefresh} />
)} {/* ===== Barre d'actions groupées ===== */} {/* ===== Menu contextuel fichier ===== */} {contextMenu && ( setContextMenu(null)} onRefresh={handleRefresh} onPreview={() => setPreviewFile(contextMenu.file)} /> )} {/* ===== Menu contextuel fond ===== */} {bgMenu && ( e.stopPropagation()} role="menu" > {[ { icon: FolderPlus, label: "Nouveau dossier", hint: "⌘⇧N", run: () => setShowCreateFolder(true) }, { icon: Upload, label: "Envoyer des fichiers", hint: "U", run: () => { setShowUpload(true); uploadRef.current?.openPicker(); } }, { icon: CheckSquare, label: "Tout sélectionner", hint: "⌘A", run: () => { setSelectMode(true); selectAll(); } }, ].map((it) => ( ))} )} {/* ===== Overlay de dépôt natif ===== */} {nativeDragOver && (

Déposer pour envoyer

dans {currentName}

)}
setShowCreateFolder(false)} parentId={folderId || null} onCreated={handleRefresh} /> setShowCreateSharedFolder(false)} onCreated={handleRefresh} />
); }