SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
15.2 KB · 352 lines tsx
Raw Blame History
1"use client";23import { useCallback, useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";4import { useDropzone } from "react-dropzone";5import { motion, AnimatePresence } from "framer-motion";6import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ChevronUp, Clock, Ban, FolderUp } from "lucide-react";7import { formatFileSize } from "@/lib/utils";89type UploadStatus = "waiting" | "uploading" | "done" | "error" | "cancelled";1011interface UploadItem {12  id: string;13  file: File;14  progress: number;15  status: UploadStatus;16  error?: string;17  xhr?: XMLHttpRequest;18}1920interface FileUploadProps {21  folderId?: string | null;22  onUploadComplete: () => void;23  /** Afficher la zone de dépôt (la file d'attente reste visible même si false) */24  showZone?: boolean;25  /** Nom du dossier courant (affiché dans la zone) */26  folderName?: string;27}2829/** API impérative : permet d'injecter des fichiers (dépôt global, palette…) */30export interface FileUploadHandle {31  addFiles: (files: File[]) => void;32  openPicker: () => void;33}3435const CONCURRENT_LIMIT = 2;3637const FileUpload = forwardRef<FileUploadHandle, FileUploadProps>(function FileUpload(38  { folderId, onUploadComplete, showZone = true, folderName },39  ref40) {41  const [uploads, setUploads] = useState<UploadItem[]>([]);42  const [collapsed, setCollapsed] = useState(false);43  const [showSummary, setShowSummary] = useState(false);44  const activeCount = useRef(0);45  const queueRef = useRef<UploadItem[]>([]);46  const uploadsRef = useRef<UploadItem[]>([]);47  const completeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);4849  useEffect(() => {50    uploadsRef.current = uploads;51  }, [uploads]);5253  const updateUpload = useCallback((id: string, patch: Partial<UploadItem>) => {54    setUploads((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u)));55  }, []);5657  // Rafraîchit la liste au plus une fois par demi-seconde (évite N refetch pour N fichiers)58  const scheduleComplete = useCallback(() => {59    if (completeTimer.current) clearTimeout(completeTimer.current);60    completeTimer.current = setTimeout(() => onUploadComplete(), 400);61  }, [onUploadComplete]);6263  const checkAllDone = () => {64    setTimeout(() => {65      setUploads((prev) => {66        const allFinished = prev.every((u) => u.status === "done" || u.status === "error" || u.status === "cancelled");67        if (allFinished && prev.length > 0) setShowSummary(true);68        return prev;69      });70    }, 200);71  };7273  const processQueue = useCallback(() => {74    while (activeCount.current < CONCURRENT_LIMIT && queueRef.current.length > 0) {75      const item = queueRef.current.shift();76      if (!item) break;77      const current = uploadsRef.current.find((u) => u.id === item.id);78      if (current && current.status === "cancelled") continue;79      activeCount.current++;80      startUpload(item);81    }82    // eslint-disable-next-line react-hooks/exhaustive-deps83  }, []);8485  const startUpload = useCallback(86    (item: UploadItem) => {87      const xhr = new XMLHttpRequest();88      const formData = new FormData();89      formData.append("file", item.file);90      if (folderId) formData.append("folderId", folderId);9192      updateUpload(item.id, { status: "uploading", xhr });9394      xhr.upload.addEventListener("progress", (e) => {95        if (e.lengthComputable) updateUpload(item.id, { progress: Math.round((e.loaded / e.total) * 100) });96      });9798      xhr.addEventListener("load", () => {99        activeCount.current--;100        if (xhr.status >= 200 && xhr.status < 300) {101          updateUpload(item.id, { progress: 100, status: "done", xhr: undefined });102          scheduleComplete();103        } else {104          let errorMsg = "Erreur d'envoi";105          try {106            const data = JSON.parse(xhr.responseText);107            errorMsg = data.error || errorMsg;108          } catch {}109          updateUpload(item.id, { status: "error", error: errorMsg, xhr: undefined });110        }111        processQueue();112        checkAllDone();113      });114115      xhr.addEventListener("error", () => {116        activeCount.current--;117        updateUpload(item.id, { status: "error", error: "Erreur réseau", xhr: undefined });118        processQueue();119        checkAllDone();120      });121122      xhr.addEventListener("abort", () => {123        activeCount.current--;124        updateUpload(item.id, { status: "cancelled", xhr: undefined });125        processQueue();126        checkAllDone();127      });128129      xhr.open("POST", "/api/files");130      xhr.send(formData);131    },132    // eslint-disable-next-line react-hooks/exhaustive-deps133    [folderId, scheduleComplete, updateUpload, processQueue]134  );135136  const cancelUpload = useCallback((id: string) => {137    setUploads((prev) =>138      prev.map((u) => {139        if (u.id === id) {140          if (u.xhr) u.xhr.abort();141          if (u.status === "waiting") {142            queueRef.current = queueRef.current.filter((q) => q.id !== id);143            return { ...u, status: "cancelled" as UploadStatus };144          }145        }146        return u;147      })148    );149  }, []);150151  const cancelAll = useCallback(() => {152    queueRef.current = [];153    setUploads((prev) =>154      prev.map((u) => {155        if (u.status === "uploading" && u.xhr) u.xhr.abort();156        if (u.status === "waiting" || u.status === "uploading") return { ...u, status: "cancelled" as UploadStatus, xhr: undefined };157        return u;158      })159    );160    activeCount.current = 0;161  }, []);162163  const clearUploads = useCallback(() => {164    setUploads([]);165    setShowSummary(false);166    queueRef.current = [];167    activeCount.current = 0;168  }, []);169170  const addFiles = useCallback(171    (acceptedFiles: File[]) => {172      if (!acceptedFiles.length) return;173      setShowSummary(false);174      setCollapsed(false);175      const newItems: UploadItem[] = acceptedFiles.map((file) => ({176        id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,177        file,178        progress: 0,179        status: "waiting" as UploadStatus,180      }));181      setUploads((prev) => [...prev, ...newItems]);182      queueRef.current.push(...newItems);183      processQueue();184    },185    [processQueue]186  );187188  const { getRootProps, getInputProps, isDragActive, open } = useDropzone({189    onDrop: addFiles,190    noClick: false,191    noKeyboard: false,192    // La zone gère ses propres dépôts ; l'overlay global (FileExplorer) gère le reste.193    noDragEventsBubbling: true,194  });195196  useImperativeHandle(ref, () => ({ addFiles, openPicker: open }), [addFiles, open]);197198  const doneCount = uploads.filter((u) => u.status === "done").length;199  const errorCount = uploads.filter((u) => u.status === "error").length;200  const cancelledCount = uploads.filter((u) => u.status === "cancelled").length;201  const hasActive = uploads.some((u) => u.status === "uploading" || u.status === "waiting");202  const totalProgress = uploads.length203    ? Math.round(uploads.reduce((s, u) => s + (u.status === "done" ? 100 : u.status === "uploading" ? u.progress : 0), 0) / uploads.length)204    : 0;205  const maxSize = parseInt(process.env.NEXT_PUBLIC_MAX_FILE_SIZE || "104857600");206207  return (208    <div>209      {/* Zone de dépôt */}210      <AnimatePresence initial={false}>211        {showZone && (212          <motion.div213            key="zone"214            initial={{ opacity: 0, height: 0 }}215            animate={{ opacity: 1, height: "auto" }}216            exit={{ opacity: 0, height: 0 }}217            transition={{ duration: 0.2 }}218            className="overflow-hidden"219          >220            <div221              {...getRootProps()}222              className={`relative group border-2 border-dashed rounded-2xl p-6 sm:p-8 text-center cursor-pointer transition-all ${223                isDragActive224                  ? "border-blue-500 bg-blue-500/[0.06] shadow-glow"225                  : "border-gray-300 dark:border-white/15 hover:border-blue-400 dark:hover:border-blue-400/60 hover:bg-blue-500/[0.03]"226              }`}227            >228              <input {...getInputProps()} />229              <div className={`mx-auto mb-3 w-12 h-12 rounded-2xl flex items-center justify-center transition-transform ${isDragActive ? "bg-blue-500 text-white scale-110" : "bg-blue-500/10 text-blue-600 dark:text-blue-300 group-hover:scale-105"}`}>230                <Upload className="w-5 h-5" />231              </div>232              <p className="text-sm font-medium text-gray-800 dark:text-gray-100">233                {isDragActive ? "Déposez les fichiers ici" : (234                  <>235                    <span className="hidden sm:inline">Glissez-déposez vos fichiers ou <span className="text-blue-600 dark:text-blue-300">parcourez</span></span>236                    <span className="sm:hidden">Touchez pour choisir des fichiers</span>237                  </>238                )}239              </p>240              <p className="text-[12px] text-gray-400 mt-1">241                {folderName ? <>Dans <span className="font-medium text-gray-500 dark:text-gray-300">{folderName}</span> · </> : null}242                Max {formatFileSize(maxSize)} par fichier243              </p>244            </div>245          </motion.div>246        )}247      </AnimatePresence>248249      {/* File d'attente */}250      <AnimatePresence>251        {uploads.length > 0 && (252          <motion.div253            initial={{ opacity: 0, height: 0 }}254            animate={{ opacity: 1, height: "auto" }}255            exit={{ opacity: 0, height: 0 }}256            className={`${showZone ? "mt-3" : ""} card overflow-hidden`}257          >258            <div className="relative flex items-center justify-between px-4 py-3 border-b border-line dark:border-white/[0.07]">259              {/* Barre de progression globale (fine, en haut) */}260              {hasActive && (261                <div className="absolute left-0 right-0 top-0 h-0.5 bg-gray-100 dark:bg-white/5">262                  <motion.div className="h-full bg-accent-gradient" animate={{ width: `${totalProgress}%` }} transition={{ ease: "easeOut", duration: 0.3 }} />263                </div>264              )}265              <button onClick={() => setCollapsed(!collapsed)} className="flex items-center gap-2 text-[13px] font-medium text-gray-800 dark:text-gray-100 min-h-[36px]">266                {collapsed ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronUp className="w-4 h-4 text-gray-400" />}267                <FolderUp className="w-4 h-4 text-blue-500" />268                <span>269                  {hasActive ? `Envoi ${doneCount}/${uploads.length}` : `${doneCount}/${uploads.length} fichier${uploads.length > 1 ? "s" : ""} envoyé${doneCount > 1 ? "s" : ""}`}270                </span>271                {hasActive && <span className="text-[12px] text-blue-600 dark:text-blue-300 tabular-nums">{totalProgress}%</span>}272              </button>273              <div className="flex items-center gap-1">274                {hasActive ? (275                  <button onClick={cancelAll} className="text-[12px] text-rose-600 hover:text-rose-700 dark:text-rose-400 px-2.5 h-8 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-500/10 font-medium transition-colors">276                    Tout annuler277                  </button>278                ) : (279                  <button onClick={clearUploads} className="text-[12px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 px-2.5 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">280                    Effacer281                  </button>282                )}283              </div>284            </div>285286            <AnimatePresence initial={false}>287              {!collapsed && (288                <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="max-h-64 overflow-y-auto">289                  <div className="divide-y divide-line dark:divide-white/5">290                    {uploads.map((upload) => (291                      <UploadRow key={upload.id} upload={upload} onCancel={() => cancelUpload(upload.id)} />292                    ))}293                  </div>294                </motion.div>295              )}296            </AnimatePresence>297298            <AnimatePresence>299              {showSummary && !hasActive && (300                <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="px-4 py-2.5 border-t border-line dark:border-white/[0.07] text-[12px] text-gray-500 dark:text-gray-400 flex items-center gap-3 bg-gray-50/60 dark:bg-white/[0.02]">301                  {doneCount > 0 && <span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400"><CheckCircle className="w-3.5 h-3.5" />{doneCount} terminé{doneCount > 1 ? "s" : ""}</span>}302                  {errorCount > 0 && <span className="flex items-center gap-1 text-rose-500 dark:text-rose-400"><AlertCircle className="w-3.5 h-3.5" />{errorCount} erreur{errorCount > 1 ? "s" : ""}</span>}303                  {cancelledCount > 0 && <span className="flex items-center gap-1 text-gray-400"><Ban className="w-3.5 h-3.5" />{cancelledCount} annulé{cancelledCount > 1 ? "s" : ""}</span>}304                </motion.div>305              )}306            </AnimatePresence>307          </motion.div>308        )}309      </AnimatePresence>310    </div>311  );312});313314export default FileUpload;315316function UploadRow({ upload, onCancel }: { upload: UploadItem; onCancel: () => void }) {317  const { file, progress, status, error } = upload;318  return (319    <div className="px-4 py-2.5">320      <div className="flex items-center gap-3">321        <div className="shrink-0 w-4 flex justify-center">322          {status === "waiting" && <Clock className="w-4 h-4 text-gray-400" />}323          {status === "uploading" && <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />}324          {status === "done" && <CheckCircle className="w-4 h-4 text-emerald-500" />}325          {status === "error" && <AlertCircle className="w-4 h-4 text-rose-500" />}326          {status === "cancelled" && <Ban className="w-4 h-4 text-gray-400" />}327        </div>328        <div className="flex-1 min-w-0">329          <div className="flex items-center justify-between gap-2">330            <span className={`text-[13px] truncate ${status === "cancelled" ? "text-gray-400 line-through" : status === "error" ? "text-rose-500 dark:text-rose-400" : "text-gray-700 dark:text-gray-200"}`}>{file.name}</span>331            <div className="flex items-center gap-2 shrink-0">332              <span className="text-[11.5px] text-gray-400 tabular-nums">{formatFileSize(file.size)}</span>333              {status === "uploading" && <span className="text-[11.5px] font-medium text-blue-600 dark:text-blue-300 tabular-nums w-9 text-right">{progress}%</span>}334            </div>335          </div>336          {(status === "uploading" || status === "waiting") && (337            <div className="mt-1.5 h-1 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">338              <motion.div className="h-full bg-accent-gradient rounded-full" initial={{ width: 0 }} animate={{ width: `${progress}%` }} transition={{ duration: 0.3, ease: "easeOut" }} />339            </div>340          )}341          {status === "error" && error && <p className="text-[11.5px] text-rose-400 mt-0.5">{error}</p>}342        </div>343        {(status === "uploading" || status === "waiting") && (344          <button onClick={onCancel} className="shrink-0 w-8 h-8 rounded-lg hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors flex items-center justify-center" title="Annuler" aria-label="Annuler">345            <X className="w-3.5 h-3.5 text-gray-400" />346          </button>347        )}348      </div>349    </div>350  );351}352