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%
7.4 KB · 176 lines tsx
Raw Blame History
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