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