JavaScript 56.7%
TypeScript 42.6%
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