JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useEffect, useLayoutEffect, useRef, useState } from "react";4import { motion } from "framer-motion";5import { Download, Pencil, Trash2, Copy, Share2, FolderInput, Star, Eye } from "lucide-react";6import toast from "react-hot-toast";7import Modal from "../ui/Modal";8import Button from "../ui/Button";9import MoveModal from "./MoveModal";10import ShareModal from "./ShareModal";1112interface FileData {13 id: string;14 name: string;15 size: number;16 mimeType: string;17 folderId?: string | null;18 metadata?: { isFavorite: boolean } | null;19}2021interface FileActionsProps {22 file: FileData;23 onClose: () => void;24 onRefresh: () => void;25 /** Position fixe (menu contextuel) ; sinon ancré sous le déclencheur (absolute). */26 anchor?: { x: number; y: number };27 /** Ouvrir l'aperçu depuis le menu contextuel */28 onPreview?: () => void;29}3031export default function FileActions({ file, onClose, onRefresh, anchor, onPreview }: FileActionsProps) {32 const [renameModal, setRenameModal] = useState(false);33 const [shareModal, setShareModal] = useState(false);34 const [moveModal, setMoveModal] = useState(false);35 const [deleteModal, setDeleteModal] = useState(false);36 const [newName, setNewName] = useState(file.name);37 const [loading, setLoading] = useState(false);38 const menuRef = useRef<HTMLDivElement>(null);39 const [pos, setPos] = useState<{ x: number; y: number } | null>(anchor ?? null);4041 // Clamp du menu contextuel dans le viewport42 useLayoutEffect(() => {43 if (!anchor || !menuRef.current) return;44 const r = menuRef.current.getBoundingClientRect();45 const pad = 8;46 const x = Math.min(anchor.x, window.innerWidth - r.width - pad);47 const y = Math.min(anchor.y, window.innerHeight - r.height - pad);48 setPos({ x: Math.max(pad, x), y: Math.max(pad, y) });49 }, [anchor]);5051 // Fermer le menu contextuel : clic ailleurs / Échap / scroll52 useEffect(() => {53 if (!anchor) return;54 const anyModal = renameModal || shareModal || moveModal || deleteModal;55 if (anyModal) return;56 const onDown = (e: MouseEvent) => {57 if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();58 };59 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };60 document.addEventListener("mousedown", onDown);61 document.addEventListener("keydown", onKey);62 window.addEventListener("scroll", onClose, true);63 return () => {64 document.removeEventListener("mousedown", onDown);65 document.removeEventListener("keydown", onKey);66 window.removeEventListener("scroll", onClose, true);67 };68 }, [anchor, onClose, renameModal, shareModal, moveModal, deleteModal]);6970 const handleDownload = () => {71 window.open(`/api/files/${file.id}/download`, "_blank");72 onClose();73 };7475 const handleToggleFavorite = async () => {76 try {77 const res = await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });78 if (res.ok) {79 const meta = await res.json().catch(() => null);80 toast.success(meta?.isFavorite ? "Ajouté aux favoris" : "Retiré des favoris");81 onRefresh();82 }83 } catch {84 toast.error("Erreur");85 }86 onClose();87 };8889 const handleRename = async () => {90 if (!newName.trim()) return;91 setLoading(true);92 try {93 const res = await fetch(`/api/files/${file.id}`, {94 method: "PATCH",95 headers: { "Content-Type": "application/json" },96 body: JSON.stringify({ name: newName.trim() }),97 });98 if (res.ok) {99 toast.success("Fichier renommé");100 onRefresh();101 } else toast.error("Renommage impossible");102 } catch {103 toast.error("Erreur");104 } finally {105 setLoading(false);106 setRenameModal(false);107 onClose();108 }109 };110111 const handleDuplicate = async () => {112 try {113 const res = await fetch(`/api/files/${file.id}/duplicate`, { method: "POST" });114 if (res.ok) {115 toast.success("Fichier dupliqué");116 onRefresh();117 } else toast.error("Duplication impossible");118 } catch {119 toast.error("Erreur");120 }121 onClose();122 };123124 const handleDelete = async () => {125 setLoading(true);126 try {127 const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" });128 if (res.ok) {129 toast.success("Fichier déplacé dans la corbeille");130 onRefresh();131 } else toast.error("Suppression impossible");132 } catch {133 toast.error("Erreur");134 } finally {135 setLoading(false);136 setDeleteModal(false);137 onClose();138 }139 };140141 const handleMove = async (targetFolderId: string) => {142 try {143 const res = await fetch(`/api/files/${file.id}`, {144 method: "PATCH",145 headers: { "Content-Type": "application/json" },146 body: JSON.stringify({ folderId: targetFolderId }),147 });148 if (res.ok) {149 toast.success("Fichier déplacé");150 onRefresh();151 } else toast.error("Déplacement impossible");152 } catch {153 toast.error("Erreur");154 }155 setMoveModal(false);156 onClose();157 };158159 const isFav = !!file.metadata?.isFavorite;160161 type Action = { icon: typeof Download; label: string; action: () => void; danger?: boolean; sep?: boolean; hint?: string };162 const actions: Action[] = [163 ...(onPreview ? [{ icon: Eye, label: "Aperçu", action: () => { onPreview(); onClose(); }, hint: "↵" }] : []),164 { icon: Download, label: "Télécharger", action: handleDownload },165 { icon: Star, label: isFav ? "Retirer des favoris" : "Ajouter aux favoris", action: handleToggleFavorite },166 { icon: Share2, label: "Partager…", action: () => setShareModal(true), sep: true },167 { icon: Pencil, label: "Renommer…", action: () => setRenameModal(true) },168 { icon: Copy, label: "Dupliquer", action: handleDuplicate },169 { icon: FolderInput, label: "Déplacer vers…", action: () => setMoveModal(true) },170 { icon: Trash2, label: "Supprimer", action: () => setDeleteModal(true), danger: true, sep: true, hint: "⌫" },171 ];172173 const anyModal = renameModal || shareModal || moveModal || deleteModal;174175 const menu = (176 <motion.div177 ref={menuRef}178 initial={{ opacity: 0, scale: 0.96, y: -4 }}179 animate={{ opacity: 1, scale: 1, y: 0 }}180 transition={{ duration: 0.14, ease: [0.2, 0.8, 0.2, 1] }}181 role="menu"182 className={`glass rounded-2xl shadow-pop p-1.5 w-56 max-h-[70vh] overflow-auto ${183 anchor ? "fixed z-[75]" : "absolute right-0 top-full mt-1.5 z-50"184 } ${anchor && !pos ? "invisible" : ""} ${anyModal ? "pointer-events-none opacity-0" : ""}`}185 style={anchor && pos ? { left: pos.x, top: pos.y } : undefined}186 onClick={(e) => e.stopPropagation()}187 onContextMenu={(e) => e.preventDefault()}188 >189 {actions.map((a) => (190 <div key={a.label}>191 {a.sep && <div className="my-1 border-t border-line dark:border-white/10" />}192 <button193 role="menuitem"194 onClick={(e) => { e.stopPropagation(); a.action(); }}195 className={`flex items-center gap-2.5 w-full px-3 h-10 sm:h-9 rounded-xl text-[13px] text-left transition-colors ${196 a.danger197 ? "text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10"198 : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"199 }`}200 >201 <a.icon className={`w-4 h-4 shrink-0 ${a.danger ? "" : a.label.includes("favoris") && isFav ? "text-amber-400 fill-amber-400" : "text-gray-400 dark:text-gray-500"}`} />202 <span className="flex-1 truncate">{a.label}</span>203 {a.hint && <kbd>{a.hint}</kbd>}204 </button>205 </div>206 ))}207 </motion.div>208 );209210 return (211 <>212 {menu}213214 <Modal isOpen={renameModal} onClose={() => { setRenameModal(false); onClose(); }} title="Renommer">215 <input216 type="text"217 value={newName}218 onChange={(e) => setNewName(e.target.value)}219 onKeyDown={(e) => e.key === "Enter" && handleRename()}220 onFocus={(e) => {221 const dot = file.name.lastIndexOf(".");222 e.currentTarget.setSelectionRange(0, dot > 0 ? dot : file.name.length);223 }}224 className="field mb-4"225 autoFocus226 />227 <div className="flex gap-2 justify-end">228 <Button variant="ghost" onClick={() => { setRenameModal(false); onClose(); }}>Annuler</Button>229 <Button variant="accent" onClick={handleRename} loading={loading} disabled={!newName.trim() || newName.trim() === file.name}>Renommer</Button>230 </div>231 </Modal>232233 <Modal isOpen={deleteModal} onClose={() => { setDeleteModal(false); onClose(); }} title="Supprimer le fichier">234 <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">235 <strong className="text-gray-900 dark:text-white break-all">{file.name}</strong> sera déplacé dans la corbeille. Vous pourrez le restaurer pendant 30 jours.236 </p>237 <div className="flex gap-2 justify-end">238 <Button variant="ghost" onClick={() => { setDeleteModal(false); onClose(); }}>Annuler</Button>239 <Button variant="danger" onClick={handleDelete} loading={loading}><Trash2 className="w-4 h-4" /> Supprimer</Button>240 </div>241 </Modal>242243 <ShareModal244 isOpen={shareModal}245 onClose={() => { setShareModal(false); onClose(); }}246 target={{ type: "file", id: file.id, name: file.name }}247 />248249 <MoveModal250 isOpen={moveModal}251 onClose={() => { setMoveModal(false); onClose(); }}252 onSelect={handleMove}253 itemName={file.name}254 currentFolderId={file.folderId ?? "root"}255 />256 </>257 );258}259