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