SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
6.4 KB · 170 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState, useCallback } from "react";4import { Folder, FolderOpen, Home, ChevronRight, ChevronDown, Search } from "lucide-react";5import Modal from "../ui/Modal";6import Button from "../ui/Button";78interface TreeFolder {9  id: string;10  name: string;11  children?: TreeFolder[];12  _count?: { files: number };13}1415interface MoveModalProps {16  isOpen: boolean;17  onClose: () => void;18  /** Appelée avec "root" ou l'id du dossier cible. */19  onSelect: (folderId: string) => void;20  /** Dossier (et son sous-arbre) à masquer — pour le déplacement d'un dossier. */21  excludeId?: string;22  title?: string;23  itemName?: string;24  /** Emplacement actuel (désactivé dans la liste) */25  currentFolderId?: string | null;26}2728function matches(node: TreeFolder, q: string): boolean {29  if (!q) return true;30  if (node.name.toLowerCase().includes(q)) return true;31  return (node.children ?? []).some((c) => matches(c, q));32}3334export default function MoveModal({35  isOpen,36  onClose,37  onSelect,38  excludeId,39  title = "Déplacer vers",40  itemName,41  currentFolderId,42}: MoveModalProps) {43  const [tree, setTree] = useState<TreeFolder[]>([]);44  const [expanded, setExpanded] = useState<Set<string>>(new Set());45  const [loading, setLoading] = useState(false);46  const [query, setQuery] = useState("");47  const [chosen, setChosen] = useState<string | null>(null);4849  useEffect(() => {50    if (!isOpen) return;51    setLoading(true);52    setQuery("");53    setChosen(null);54    fetch("/api/folders?tree=true")55      .then((r) => (r.ok ? r.json() : []))56      .then((data) => {57        const t: TreeFolder[] = Array.isArray(data) ? data : [];58        setTree(t);59        setExpanded(new Set(t.filter((n) => n.children?.length).map((n) => n.id)));60      })61      .catch(() => setTree([]))62      .finally(() => setLoading(false));63  }, [isOpen]);6465  const toggle = useCallback((id: string) => {66    setExpanded((prev) => {67      const next = new Set(prev);68      next.has(id) ? next.delete(id) : next.add(id);69      return next;70    });71  }, []);7273  const q = query.trim().toLowerCase();74  const current = currentFolderId ?? undefined;7576  function renderNode(node: TreeFolder, depth: number): React.ReactNode {77    if (node.id === excludeId) return null;78    if (!matches(node, q)) return null;79    const kids = node.children ?? [];80    const hasKids = kids.length > 0;81    const isOpen = q ? true : expanded.has(node.id);82    const isCurrent = node.id === current;83    const isChosen = node.id === chosen;84    return (85      <div key={node.id}>86        <div87          className={`flex items-center gap-1 rounded-xl transition-colors ${isChosen ? "bg-blue-500/10 ring-1 ring-blue-500/30" : "hover:bg-gray-100 dark:hover:bg-white/5"}`}88          style={{ paddingLeft: depth * 16 }}89        >90          <button91            type="button"92            onClick={() => hasKids && toggle(node.id)}93            className="w-7 h-10 flex items-center justify-center shrink-0 text-gray-400"94            tabIndex={hasKids ? 0 : -1}95            aria-label={hasKids ? (isOpen ? "Replier" : "Déplier") : undefined}96          >97            {hasKids ? (isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />) : null}98          </button>99          <button100            type="button"101            disabled={isCurrent}102            onClick={() => setChosen(node.id)}103            onDoubleClick={() => !isCurrent && onSelect(node.id)}104            className={`flex items-center gap-2 flex-1 min-w-0 h-10 pr-2 text-sm text-left rounded-r-xl ${isCurrent ? "text-gray-400 cursor-not-allowed" : "text-gray-700 dark:text-gray-200"}`}105          >106            {isOpen && hasKids ? <FolderOpen className="w-4 h-4 text-blue-500 shrink-0" /> : <Folder className="w-4 h-4 text-blue-400 shrink-0" />}107            <span className="truncate flex-1">{node.name}</span>108            {isCurrent && <span className="text-[10px] uppercase tracking-wider text-gray-400">actuel</span>}109            {!isCurrent && node._count?.files ? <span className="text-[11px] text-gray-400 tabular-nums">{node._count.files}</span> : null}110          </button>111        </div>112        {hasKids && isOpen && <div className="border-l border-line dark:border-white/[0.07] ml-[13px]">{kids.map((c) => renderNode(c, depth + 1))}</div>}113      </div>114    );115  }116117  const rootIsCurrent = current === "root" || current === null;118119  return (120    <Modal121      isOpen={isOpen}122      onClose={onClose}123      title={title}124      description={itemName ? `Choisissez la destination de « ${itemName} »` : undefined}125      footer={126        <>127          <Button variant="ghost" onClick={onClose}>Annuler</Button>128          <Button variant="accent" disabled={!chosen} onClick={() => chosen && onSelect(chosen)}>Déplacer ici</Button>129        </>130      }131    >132      <div className="relative mb-3">133        <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />134        <input135          type="text"136          value={query}137          onChange={(e) => setQuery(e.target.value)}138          placeholder="Filtrer les dossiers…"139          className="field pl-9"140        />141      </div>142      <div className="space-y-0.5 max-h-[48vh] overflow-y-auto -mx-1 px-1">143        <button144          type="button"145          disabled={rootIsCurrent}146          onClick={() => setChosen("root")}147          onDoubleClick={() => !rootIsCurrent && onSelect("root")}148          className={`flex items-center gap-2 w-full px-2 h-10 text-sm font-medium rounded-xl transition-colors ${149            chosen === "root" ? "bg-blue-500/10 ring-1 ring-blue-500/30 text-gray-900 dark:text-white" : rootIsCurrent ? "text-gray-400 cursor-not-allowed" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5"150          }`}151        >152          <Home className="w-4 h-4 text-gray-500 shrink-0" />153          <span className="flex-1 text-left">Racine (Fichiers)</span>154          {rootIsCurrent && <span className="text-[10px] uppercase tracking-wider text-gray-400">actuel</span>}155        </button>156        {loading ? (157          <div className="space-y-2 p-2">158            {[0, 1, 2].map((i) => <div key={i} className="h-9 rounded-xl bg-gray-100 dark:bg-white/5 animate-pulse" />)}159          </div>160        ) : (161          tree.map((n) => renderNode(n, 0))162        )}163        {!loading && q && tree.every((n) => !matches(n, q)) && (164          <p className="px-2 py-4 text-sm text-gray-400 text-center">Aucun dossier ne correspond</p>165        )}166      </div>167    </Modal>168  );169}170