SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
yesterdaylast push
JavaScript 56.7% TypeScript 42.6%
13.1 KB · 309 lines tsx
Raw Blame History
1"use client";23import { useRouter } from "next/navigation";4import { motion } from "framer-motion";5import { Folder, FolderOpen, MoreVertical, Pencil, Trash2, FolderInput, ChevronRight, Share2, Download } from "lucide-react";6import { useState, useRef, useEffect, memo } from "react";7import toast from "react-hot-toast";8import Modal from "../ui/Modal";9import Button from "../ui/Button";10import SharedBadge from "../ui/SharedBadge";11import MoveModal from "../files/MoveModal";12import ShareModal from "../files/ShareModal";13import { DND_FOLDER, handleInternalDrop, hasInternalDrag, notifyFoldersChanged, readFolderId, setDragGhost } from "../files/dnd";1415interface FolderData {16  id: string;17  name: string;18  parentId?: string | null;19  sharedSpaceId?: string | null;20  _count?: { files: number; children: number };21}2223interface FolderCardProps {24  folder: FolderData;25  onRefresh: () => void;26  viewMode: "grid" | "list";27  /** Navigation personnalisée (espaces partagés) */28  onOpen?: (folder: FolderData) => void;29  focusable?: boolean;30}3132function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: FolderCardProps) {33  const router = useRouter();34  const [showMenu, setShowMenu] = useState(false);35  const [renameModal, setRenameModal] = useState(false);36  const [deleteModal, setDeleteModal] = useState(false);37  const [moveModal, setMoveModal] = useState(false);38  const [shareModal, setShareModal] = useState(false);39  const [newName, setNewName] = useState(folder.name);40  const [loading, setLoading] = useState(false);41  const [dragOver, setDragOver] = useState(false);42  const [dragging, setDragging] = useState(false);43  const menuRef = useRef<HTMLDivElement>(null);44  const overCount = useRef(0);4546  useEffect(() => {47    if (!showMenu) return;48    function handleClick(e: MouseEvent) {49      if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowMenu(false);50    }51    document.addEventListener("mousedown", handleClick);52    return () => document.removeEventListener("mousedown", handleClick);53  }, [showMenu]);5455  const open = () => (onOpen ? onOpen(folder) : router.push(`/dashboard/folder/${folder.id}`));5657  const handleMove = async (targetFolderId: string) => {58    try {59      const res = await fetch(`/api/folders/${folder.id}`, {60        method: "PATCH",61        headers: { "Content-Type": "application/json" },62        body: JSON.stringify({ parentId: targetFolderId }),63      });64      if (res.ok) {65        toast.success("Dossier déplacé");66        notifyFoldersChanged();67        onRefresh();68      } else {69        const d = await res.json().catch(() => ({}));70        toast.error(d.error || "Déplacement impossible");71      }72    } catch {73      toast.error("Erreur");74    }75    setMoveModal(false);76  };7778  const handleRename = async () => {79    if (!newName.trim()) return;80    setLoading(true);81    try {82      const res = await fetch(`/api/folders/${folder.id}`, {83        method: "PATCH",84        headers: { "Content-Type": "application/json" },85        body: JSON.stringify({ name: newName.trim() }),86      });87      if (res.ok) {88        toast.success("Dossier renommé");89        notifyFoldersChanged();90        onRefresh();91      } else toast.error("Renommage impossible");92    } catch {93      toast.error("Erreur");94    } finally {95      setLoading(false);96      setRenameModal(false);97    }98  };99100  const handleDelete = async () => {101    setLoading(true);102    try {103      const res = await fetch(`/api/folders/${folder.id}`, { method: "DELETE" });104      if (res.ok) {105        toast.success("Dossier supprimé");106        notifyFoldersChanged();107        onRefresh();108      } else toast.error("Suppression impossible");109    } catch {110      toast.error("Erreur");111    } finally {112      setLoading(false);113      setDeleteModal(false);114    }115  };116117  /* ---- DnD : source ---- */118  const onDragStart = (e: React.DragEvent) => {119    e.dataTransfer.setData(DND_FOLDER, folder.id);120    e.dataTransfer.setData("text/plain", folder.name);121    e.dataTransfer.effectAllowed = "move";122    setDragGhost(e, `📁 ${folder.name}`);123    setDragging(true);124  };125126  /* ---- DnD : cible ---- */127  const acceptable = (e: React.DragEvent) => hasInternalDrag(e.dataTransfer) && !dragging;128  const onDragEnter = (e: React.DragEvent) => {129    if (!acceptable(e)) return;130    e.preventDefault();131    overCount.current++;132    setDragOver(true);133  };134  const onDragOver = (e: React.DragEvent) => {135    if (!acceptable(e)) return;136    e.preventDefault();137    e.dataTransfer.dropEffect = "move";138  };139  const onDragLeave = (e: React.DragEvent) => {140    if (!acceptable(e)) return;141    overCount.current = Math.max(0, overCount.current - 1);142    if (overCount.current === 0) setDragOver(false);143  };144  const onDrop = async (e: React.DragEvent) => {145    if (!acceptable(e)) return;146    e.preventDefault();147    e.stopPropagation();148    overCount.current = 0;149    setDragOver(false);150    if (readFolderId(e.dataTransfer) === folder.id) return;151    const moved = await handleInternalDrop(e.dataTransfer, folder.id);152    if (moved) onRefresh();153  };154155  const dndProps = {156    draggable: true,157    onDragStart,158    onDragEnd: () => setDragging(false),159    onDragEnter,160    onDragOver,161    onDragLeave,162    onDrop,163    "data-folder-id": folder.id,164    tabIndex: focusable ? 0 : -1,165    onKeyDown: (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); open(); } },166  } as const;167168  const menuItems = [169    { icon: Download, label: "Télécharger en ZIP", onClick: () => window.open(`/api/folders/${folder.id}/download`, "_blank") },170    { icon: Share2, label: "Partager…", onClick: () => setShareModal(true) },171    { icon: Pencil, label: "Renommer…", onClick: () => setRenameModal(true) },172    { icon: FolderInput, label: "Déplacer vers…", onClick: () => setMoveModal(true) },173    { icon: Trash2, label: "Supprimer", onClick: () => setDeleteModal(true), danger: true },174  ];175176  const Menu = () =>177    showMenu ? (178      <motion.div179        initial={{ opacity: 0, scale: 0.96, y: -4 }}180        animate={{ opacity: 1, scale: 1, y: 0 }}181        transition={{ duration: 0.14 }}182        className="absolute right-0 top-full mt-1.5 w-52 glass rounded-2xl shadow-pop z-50 p-1.5"183        role="menu"184      >185        {menuItems.map((it, i) => (186          <div key={it.label}>187            {i === menuItems.length - 1 && <div className="my-1 border-t border-line dark:border-white/10" />}188            <button189              role="menuitem"190              onClick={(e) => { e.stopPropagation(); it.onClick(); setShowMenu(false); }}191              className={`flex items-center gap-2.5 w-full px-3 h-10 sm:h-9 rounded-xl text-[13px] transition-colors ${192                it.danger ? "text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"193              }`}194            >195              <it.icon className={`w-4 h-4 ${it.danger ? "" : "text-gray-400"}`} /> {it.label}196            </button>197          </div>198        ))}199      </motion.div>200    ) : null;201202  const modals = (203    <>204      <Modal isOpen={renameModal} onClose={() => setRenameModal(false)} title="Renommer le dossier">205        <input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename()} className="field mb-4" autoFocus onFocus={(e) => e.currentTarget.select()} />206        <div className="flex gap-2 justify-end">207          <Button variant="ghost" onClick={() => setRenameModal(false)}>Annuler</Button>208          <Button variant="accent" onClick={handleRename} loading={loading} disabled={!newName.trim() || newName.trim() === folder.name}>Renommer</Button>209        </div>210      </Modal>211      <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer le dossier">212        <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">213          Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong>214          {folder._count ? <> ({folder._count.files} fichier{folder._count.files !== 1 ? "s" : ""}, {folder._count.children} sous-dossier{folder._count.children !== 1 ? "s" : ""})</> : null} ?215          Les fichiers qu'il contient seront placés dans la corbeille et restaurables pendant 30 jours.216        </p>217        <div className="flex gap-2 justify-end">218          <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>219          <Button variant="danger" onClick={handleDelete} loading={loading}><Trash2 className="w-4 h-4" /> Supprimer</Button>220        </div>221      </Modal>222      <MoveModal isOpen={moveModal} onClose={() => setMoveModal(false)} onSelect={handleMove} excludeId={folder.id} itemName={folder.name} currentFolderId={folder.parentId ?? "root"} />223      <ShareModal isOpen={shareModal} onClose={() => setShareModal(false)} target={{ type: "folder", id: folder.id, name: folder.name }} />224    </>225  );226227  const count = folder._count;228  const countLabel = count ? [count.files ? `${count.files} fich.` : null, count.children ? `${count.children} doss.` : null].filter(Boolean).join(" · ") || "Vide" : "";229230  /* ================= LISTE ================= */231  if (viewMode === "list") {232    return (233      <>234        <div235          {...dndProps}236          className={`group relative flex items-center gap-3 px-3 density-row cursor-pointer select-none transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${237            dragOver ? "drop-target" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"238          } ${dragging ? "opacity-40" : ""}`}239          onClick={open}240          role="option"241          aria-selected={false}242        >243          <div className="w-8 h-8 shrink-0 flex items-center justify-center rounded-lg bg-blue-500/10">244            {dragOver ? <FolderOpen className="w-[18px] h-[18px] text-blue-500" /> : <Folder className="w-[18px] h-[18px] text-blue-500 fill-blue-500/20" />}245          </div>246          <div className="flex-1 min-w-0 flex items-center gap-2">247            <span className="text-[13.5px] font-medium text-gray-800 dark:text-gray-100 truncate">{folder.name}</span>248            {folder.sharedSpaceId && <SharedBadge />}249          </div>250          <span className="hidden lg:block w-24 shrink-0 text-[12px] text-gray-400">Dossier</span>251          <span className="hidden sm:block w-24 shrink-0 text-right text-[12.5px] text-gray-500 dark:text-gray-400 tabular-nums truncate">{countLabel}</span>252          <span className="hidden md:flex w-28 shrink-0 justify-end text-gray-300 dark:text-gray-600"><ChevronRight className="w-4 h-4" /></span>253          <div className="relative shrink-0 w-10 flex justify-end" ref={menuRef}>254            <button255              onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}256              className={`w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10 transition-all ${showMenu ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}257              aria-label="Actions du dossier"258            >259              <MoreVertical className="w-4 h-4" />260            </button>261            <Menu />262          </div>263        </div>264        {modals}265      </>266    );267  }268269  /* ================= GRILLE ================= */270  return (271    <>272      <div273        {...dndProps}274        className={`group relative card card-hover rounded-2xl p-3 cursor-pointer select-none min-h-[68px] outline-none animate-scale-in focus-visible:ring-2 focus-visible:ring-blue-500 ${275          dragOver ? "drop-target" : ""276        } ${dragging ? "opacity-40" : ""} ${showMenu ? "z-30" : ""}`}277        onClick={open}278      >279        <div className="flex items-center gap-3">280          <div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0 transition-transform group-hover:scale-105">281            {dragOver ? <FolderOpen className="w-5 h-5 text-blue-500" /> : <Folder className="w-5 h-5 text-blue-500 fill-blue-500/20" />}282          </div>283          <div className="flex-1 min-w-0 pr-7">284            <div className="flex items-center gap-1.5 min-w-0">285              <p className="text-[13.5px] font-medium text-gray-800 dark:text-gray-100 truncate">{folder.name}</p>286              {folder.sharedSpaceId && <SharedBadge />}287            </div>288            {count && <p className="text-[11.5px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">{countLabel}</p>}289          </div>290        </div>291        <div className="absolute top-1.5 right-1.5" ref={menuRef}>292          <button293            onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}294            className={`w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 transition-all ${showMenu ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}295            aria-label="Actions du dossier"296          >297            <MoreVertical className="w-4 h-4" />298          </button>299          <Menu />300        </div>301      </div>302      {modals}303    </>304  );305}306307const FolderCard = memo(FolderCardInner);308export default FolderCard;309