JavaScript 56.7%
TypeScript 42.6%
1"use client";23import Link from "next/link";4import { usePathname } from "next/navigation";5import { useEffect, useState, useCallback, useRef } from "react";6import { Folder, FolderOpen, ChevronRight } from "lucide-react";7import { FOLDERS_CHANGED_EVENT, handleInternalDrop, hasInternalDrag, readFolderId } from "../files/dnd";89interface TreeFolder {10 id: string;11 name: string;12 sharedSpace?: { id: string; name: string } | null;13 children?: TreeFolder[];14 _count?: { files: number };15}1617interface FolderTreeProps {18 onNavigate?: () => void;19}2021const EXPANDED_KEY = "spb-tree-expanded";2223export default function FolderTree({ onNavigate }: FolderTreeProps) {24 const pathname = usePathname();25 const [tree, setTree] = useState<TreeFolder[]>([]);26 const [expanded, setExpanded] = useState<Set<string>>(new Set());27 const [dragOverId, setDragOverId] = useState<string | null>(null);28 const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);2930 const load = useCallback(() => {31 fetch("/api/folders?tree=true")32 .then((r) => (r.ok ? r.json() : []))33 .then((d) => setTree(Array.isArray(d) ? d : []))34 .catch(() => {});35 }, []);3637 useEffect(() => {38 load();39 try {40 const saved = JSON.parse(localStorage.getItem(EXPANDED_KEY) || "[]");41 if (Array.isArray(saved)) setExpanded(new Set(saved));42 } catch {}43 window.addEventListener(FOLDERS_CHANGED_EVENT, load);44 return () => window.removeEventListener(FOLDERS_CHANGED_EVENT, load);45 }, [load]);4647 // Déplier automatiquement le chemin du dossier courant48 useEffect(() => {49 const m = pathname.match(/^\/dashboard\/folder\/([^/]+)/);50 if (!m || tree.length === 0) return;51 const target = m[1];52 const path: string[] = [];53 const find = (nodes: TreeFolder[], acc: string[]): boolean => {54 for (const n of nodes) {55 if (n.id === target) { path.push(...acc); return true; }56 if (n.children?.length && find(n.children, [...acc, n.id])) return true;57 }58 return false;59 };60 find(tree, []);61 if (path.length) setExpanded((prev) => new Set([...Array.from(prev), ...path]));62 }, [pathname, tree]);6364 const toggle = useCallback((id: string) => {65 setExpanded((prev) => {66 const next = new Set(prev);67 next.has(id) ? next.delete(id) : next.add(id);68 try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(next))); } catch {}69 return next;70 });71 }, []);7273 const clearHover = () => {74 if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }75 };7677 function renderNode(node: TreeFolder, depth: number): React.ReactNode {78 const kids = node.children ?? [];79 const hasKids = kids.length > 0;80 const isOpen = expanded.has(node.id);81 const active = pathname === `/dashboard/folder/${node.id}`;82 const over = dragOverId === node.id;8384 return (85 <div key={node.id} className="relative">86 {depth > 0 && <span className="absolute top-0 bottom-0 border-l border-line dark:border-white/[0.08]" style={{ left: 10 + (depth - 1) * 14 }} aria-hidden />}87 <div88 className={`relative flex items-center rounded-lg transition-colors ${89 over ? "drop-target" : active ? "bg-blue-500/10 dark:bg-blue-400/10" : "hover:bg-gray-100/80 dark:hover:bg-white/[0.05]"90 }`}91 style={{ paddingLeft: 2 + depth * 14 }}92 onDragEnter={(e) => {93 if (!hasInternalDrag(e.dataTransfer)) return;94 e.preventDefault();95 setDragOverId(node.id);96 clearHover();97 if (hasKids && !isOpen) hoverTimer.current = setTimeout(() => setExpanded((p) => new Set([...Array.from(p), node.id])), 600);98 }}99 onDragOver={(e) => {100 if (!hasInternalDrag(e.dataTransfer)) return;101 e.preventDefault();102 e.dataTransfer.dropEffect = "move";103 if (dragOverId !== node.id) setDragOverId(node.id);104 }}105 onDragLeave={(e) => {106 const next = e.relatedTarget as Node | null;107 if (next && e.currentTarget.contains(next)) return;108 clearHover();109 setDragOverId((cur) => (cur === node.id ? null : cur));110 }}111 onDrop={async (e) => {112 if (!hasInternalDrag(e.dataTransfer)) return;113 e.preventDefault();114 e.stopPropagation();115 clearHover();116 setDragOverId(null);117 if (readFolderId(e.dataTransfer) === node.id) return;118 const moved = await handleInternalDrop(e.dataTransfer, node.id);119 if (moved) {120 window.dispatchEvent(new Event(FOLDERS_CHANGED_EVENT));121 window.dispatchEvent(new Event("spb:files-changed"));122 }123 }}124 >125 <button126 onClick={() => hasKids && toggle(node.id)}127 className={`w-5 h-8 flex items-center justify-center shrink-0 text-gray-400 rounded ${hasKids ? "hover:text-gray-700 dark:hover:text-gray-200" : "pointer-events-none"}`}128 tabIndex={hasKids ? 0 : -1}129 aria-label={hasKids ? (isOpen ? "Replier" : "Déplier") : undefined}130 >131 {hasKids && <ChevronRight className={`w-3.5 h-3.5 transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`} />}132 </button>133 <Link134 href={`/dashboard/folder/${node.id}`}135 onClick={onNavigate}136 className={`flex items-center gap-2 flex-1 min-w-0 h-8 pr-2 text-[13px] transition-colors ${137 active ? "text-blue-700 dark:text-blue-300 font-medium" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"138 }`}139 >140 {(isOpen && hasKids) || over ? (141 <FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />142 ) : (143 <Folder className={`w-4 h-4 shrink-0 ${active ? "text-blue-500 fill-blue-500/20" : "text-blue-400"}`} />144 )}145 <span className="truncate flex-1">{node.name}</span>146 {node._count?.files ? <span className="text-[10.5px] text-gray-400 dark:text-gray-600 tabular-nums">{node._count.files}</span> : null}147 </Link>148 </div>149 {hasKids && isOpen && <div>{kids.map((c) => renderNode(c, depth + 1))}</div>}150 </div>151 );152 }153154 if (tree.length === 0) return <p className="px-3 py-2 text-[12px] text-gray-400">Aucun dossier</p>;155156 // Regroupement des racines par espace partagé (dossiers personnels d'abord)157 const groups = new Map<string, { label: string | null; nodes: TreeFolder[] }>();158 for (const n of tree) {159 const key = n.sharedSpace?.id ?? "__perso";160 if (!groups.has(key)) groups.set(key, { label: n.sharedSpace?.name ?? null, nodes: [] });161 groups.get(key)!.nodes.push(n);162 }163 const ordered = Array.from(groups.entries()).sort(([a, ga], [b, gb]) => (a === "__perso" ? -1 : b === "__perso" ? 1 : (ga.label ?? "").localeCompare(gb.label ?? "", "fr")));164 if (ordered.length <= 1) return <div className="space-y-px">{tree.map((n) => renderNode(n, 0))}</div>;165 return (166 <div className="space-y-1">167 {ordered.map(([key, g]) => (168 <div key={key}>169 <div className="px-3 pt-2 pb-1 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-gray-400 dark:text-gray-500 truncate" title={g.label ?? "Mes dossiers"}>{g.label ?? "Mes dossiers"}</div>170 <div className="space-y-px">{g.nodes.map((n) => renderNode(n, 0))}</div>171 </div>172 ))}173 </div>174 );175}176