JavaScript 56.7%
TypeScript 42.6%
1"use client";23import {4 FileText, Image, Video, Music, FileCode, FileSpreadsheet, File, Archive, MoreVertical, Star, Download, Check, type LucideIcon,5} from "lucide-react";6import { formatFileSize, getFileIcon } from "@/lib/utils";7import { useState, useRef, useEffect, memo } from "react";8import FileActions from "./FileActions";9import type { FileData } from "@/types/files";10import { DND_FILES, setDragGhost } from "./dnd";1112interface FileCardProps {13 file: FileData;14 onPreview: (file: FileData) => void;15 onRefresh: () => void;16 viewMode: "grid" | "list";17 /** Sélection (explorateur) */18 selected?: boolean;19 selectMode?: boolean;20 onToggleSelect?: (file: FileData, e: React.MouseEvent) => void;21 /** Ids à emporter dans le glisser (sélection multiple) */22 dragIds?: string[];23 /** Menu contextuel : géré par le parent (position souris) */24 onContextMenu?: (file: FileData, e: React.MouseEvent) => void;25 /** Navigation clavier (roving tabindex) */26 focusable?: boolean;27 onFocusItem?: (file: FileData) => void;28 /** Colonnes de la vue liste rendues par le parent (sticky header aligné) */29 dense?: boolean;30}3132const iconMap: Record<string, LucideIcon> = {33 image: Image, video: Video, audio: Music, pdf: FileText, code: FileCode, spreadsheet: FileSpreadsheet,34 archive: Archive, document: FileText, file: File, presentation: FileText,35};3637const iconColorMap: Record<string, string> = {38 image: "text-emerald-500", video: "text-purple-500", audio: "text-pink-500", pdf: "text-rose-500", code: "text-amber-500",39 spreadsheet: "text-green-600", archive: "text-orange-500", document: "text-blue-500", presentation: "text-orange-500", file: "text-gray-400",40};4142const bgTintMap: Record<string, string> = {43 image: "bg-emerald-50 dark:bg-emerald-950/30", video: "bg-purple-50 dark:bg-purple-950/30", audio: "bg-pink-50 dark:bg-pink-950/30",44 pdf: "bg-rose-50 dark:bg-rose-950/30", code: "bg-amber-50 dark:bg-amber-950/30", spreadsheet: "bg-green-50 dark:bg-green-950/30",45 archive: "bg-orange-50 dark:bg-orange-950/30", document: "bg-blue-50 dark:bg-blue-950/30", presentation: "bg-orange-50 dark:bg-orange-950/30",46 file: "bg-gray-50 dark:bg-white/[0.03]",47};4849const typeLabel: Record<string, string> = {50 image: "Image", video: "Vidéo", audio: "Audio", pdf: "PDF", code: "Code", spreadsheet: "Tableur", archive: "Archive",51 document: "Document", presentation: "Présentation", file: "Fichier",52};5354export function formatRelativeDate(dateStr: string): string {55 const now = Date.now();56 const then = new Date(dateStr).getTime();57 const diff = now - then;58 const minutes = Math.floor(diff / 60000);59 const hours = Math.floor(diff / 3600000);60 const days = Math.floor(diff / 86400000);61 const months = Math.floor(days / 30);62 if (minutes < 1) return "à l'instant";63 if (minutes < 60) return `il y a ${minutes} min`;64 if (hours < 24) return `il y a ${hours} h`;65 if (days < 30) return `il y a ${days} j`;66 if (months < 12) return `il y a ${months} mois`;67 return new Date(dateStr).toLocaleDateString("fr-CA", { year: "numeric", month: "short" });68}6970function extOf(name: string): string {71 const i = name.lastIndexOf(".");72 return i > 0 ? name.slice(i + 1).toUpperCase().slice(0, 5) : "";73}7475function FileCardInner({76 file, onPreview, onRefresh, viewMode, selected = false, selectMode = false, onToggleSelect, dragIds, onContextMenu, focusable, onFocusItem,77}: FileCardProps) {78 const [showActions, setShowActions] = useState(false);79 const [dragging, setDragging] = useState(false);80 const [favBusy, setFavBusy] = useState(false);81 const menuRef = useRef<HTMLDivElement>(null);8283 const iconType = getFileIcon(file.mimeType);84 const Icon = iconMap[iconType] || File;85 const iconColor = iconColorMap[iconType] || "text-gray-400";86 const bgTint = bgTintMap[iconType] || "bg-gray-50 dark:bg-white/[0.03]";87 const isImage = file.mimeType.startsWith("image/");88 const isFavorite = !!file.metadata?.isFavorite;89 const tags = file.tags ?? [];9091 useEffect(() => {92 if (!showActions) return;93 function handleClick(e: MouseEvent) {94 if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowActions(false);95 }96 document.addEventListener("mousedown", handleClick);97 return () => document.removeEventListener("mousedown", handleClick);98 }, [showActions]);99100 const onDragStart = (e: React.DragEvent) => {101 const ids = dragIds && dragIds.length && dragIds.includes(file.id) ? dragIds : [file.id];102 e.dataTransfer.setData(DND_FILES, JSON.stringify(ids));103 e.dataTransfer.setData("text/plain", file.name);104 e.dataTransfer.effectAllowed = "move";105 setDragGhost(e, ids.length > 1 ? `${ids.length} fichiers` : file.name);106 setDragging(true);107 };108 const onDragEnd = () => setDragging(false);109110 const toggleFav = async (e: React.MouseEvent) => {111 e.stopPropagation();112 if (favBusy) return;113 setFavBusy(true);114 try {115 await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });116 onRefresh();117 } catch {}118 setFavBusy(false);119 };120121 const handleClick = (e: React.MouseEvent) => {122 if (selectMode || e.metaKey || e.ctrlKey || e.shiftKey) {123 onToggleSelect?.(file, e);124 return;125 }126 onPreview(file);127 };128129 const handleKey = (e: React.KeyboardEvent) => {130 if (e.key === "Enter") { e.preventDefault(); onPreview(file); }131 else if (e.key === " ") { e.preventDefault(); onToggleSelect?.(file, e as unknown as React.MouseEvent); }132 };133134 const ctx = (e: React.MouseEvent) => {135 if (!onContextMenu) return;136 e.preventDefault();137 e.stopPropagation();138 onContextMenu(file, e);139 };140141 const commonProps = {142 draggable: true,143 onDragStart,144 onDragEnd,145 onContextMenu: ctx,146 tabIndex: focusable ? 0 : -1,147 onFocus: () => onFocusItem?.(file),148 onKeyDown: handleKey,149 "data-file-id": file.id,150 role: "option",151 "aria-selected": selected,152 } as const;153154 const Checkbox = ({ className = "" }: { className?: string }) => (155 <button156 type="button"157 aria-label={selected ? "Désélectionner" : "Sélectionner"}158 onClick={(e) => { e.stopPropagation(); onToggleSelect?.(file, e); }}159 className={`w-5 h-5 rounded-md border flex items-center justify-center transition-all ${160 selected161 ? "bg-blue-500 border-blue-500 text-white shadow-[0_0_0_3px_rgb(var(--accent-500)/.2)]"162 : "border-gray-300 dark:border-white/25 bg-white/80 dark:bg-black/40 text-transparent hover:border-blue-400"163 } ${className}`}164 >165 <Check className="w-3.5 h-3.5" strokeWidth={3} />166 </button>167 );168169 /* ================= LISTE ================= */170 if (viewMode === "list") {171 return (172 <div173 {...commonProps}174 className={`group relative flex items-center gap-3 px-3 density-row cursor-pointer select-none transition-colors outline-none ${175 selected ? "bg-blue-500/10 dark:bg-blue-400/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"176 } ${dragging ? "opacity-40" : ""} focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500`}177 onClick={handleClick}178 onDoubleClick={() => onPreview(file)}179 >180 {/* Case + icône */}181 <div className="w-8 h-8 shrink-0 relative flex items-center justify-center">182 <div className={`w-8 h-8 rounded-lg flex items-center justify-center ${bgTint} transition-opacity ${selectMode || selected ? "opacity-0" : "group-hover:opacity-0"}`}>183 {isImage ? (184 <img src={`/api/files/${file.id}/preview?w=200`} alt="" className="w-8 h-8 rounded-lg object-cover" loading="lazy" decoding="async" draggable={false} />185 ) : (186 <Icon className={`w-4 h-4 ${iconColor}`} />187 )}188 </div>189 <div className={`absolute inset-0 flex items-center justify-center transition-opacity ${selectMode || selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>190 <Checkbox />191 </div>192 </div>193194 {/* Nom */}195 <div className="flex-1 min-w-0 flex items-center gap-2">196 <span className="text-[13.5px] text-gray-800 dark:text-gray-100 truncate">{file.name}</span>197 {isFavorite && <Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 shrink-0" />}198 {tags.length > 0 && (199 <span className="hidden sm:flex items-center gap-1 shrink-0">200 {tags.slice(0, 3).map(({ tag }) => (201 <span key={tag.id} className="w-2 h-2 rounded-full ring-1 ring-black/5 dark:ring-white/10" style={{ backgroundColor: tag.color }} title={tag.name} />202 ))}203 {tags.length > 3 && <span className="text-[10px] text-gray-400">+{tags.length - 3}</span>}204 </span>205 )}206 </div>207208 {/* Type */}209 <span className="hidden lg:block w-24 shrink-0 text-[12px] text-gray-400 truncate">{extOf(file.name) || typeLabel[iconType]}</span>210 {/* Taille */}211 <span className="hidden sm:block w-24 shrink-0 text-right text-[13px] text-gray-500 dark:text-gray-400 tabular-nums">{formatFileSize(file.size)}</span>212 {/* Date */}213 <span className="hidden md:block w-28 shrink-0 text-right text-[12px] text-gray-400 tabular-nums" title={new Date(file.updatedAt || file.createdAt).toLocaleString("fr-CA")}>214 {formatRelativeDate(file.updatedAt || file.createdAt)}215 </span>216217 {/* Actions */}218 <div className="relative shrink-0 w-10 flex justify-end" ref={menuRef}>219 <button220 onClick={(e) => { e.stopPropagation(); setShowActions(!showActions); }}221 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 ${showActions ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}222 aria-label="Actions"223 >224 <MoreVertical className="w-4 h-4" />225 </button>226 {showActions && <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} onPreview={() => onPreview(file)} />}227 </div>228 </div>229 );230 }231232 /* ================= GRILLE ================= */233 return (234 <div235 {...commonProps}236 className={`group relative card card-hover rounded-2xl overflow-hidden cursor-pointer select-none outline-none animate-scale-in focus-visible:ring-2 focus-visible:ring-blue-500 ${237 selected ? "ring-2 ring-blue-500 border-transparent" : ""238 } ${dragging ? "opacity-40" : ""} ${showActions ? "z-30" : ""}`}239 onClick={handleClick}240 >241 {/* Vignette */}242 <div className={`aspect-square flex items-center justify-center overflow-hidden ${isImage ? "bg-gray-100 dark:bg-white/5" : bgTint}`}>243 {isImage ? (244 <img245 src={`/api/files/${file.id}/preview?w=400`}246 alt={file.name}247 className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"248 loading="lazy"249 decoding="async"250 draggable={false}251 />252 ) : (253 <div className="flex flex-col items-center gap-1.5">254 <Icon className={`w-10 h-10 ${iconColor}`} strokeWidth={1.6} />255 {extOf(file.name) && <span className={`text-[10px] font-semibold tracking-wider ${iconColor} opacity-80`}>{extOf(file.name)}</span>}256 </div>257 )}258 {/* Voile hover */}259 <div className="absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/35 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />260 </div>261262 {/* Case (haut gauche) */}263 <div className={`absolute top-2 left-2 z-10 transition-opacity ${selectMode || selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>264 <Checkbox />265 </div>266267 {/* Favori (haut gauche, quand pas de case) */}268 {isFavorite && !selectMode && !selected && (269 <div className="absolute top-2 left-2 z-[5] group-hover:opacity-0 transition-opacity">270 <Star className="w-4 h-4 text-amber-400 fill-amber-400 drop-shadow" />271 </div>272 )}273274 {/* Actions rapides (haut droite) */}275 <div className={`absolute top-1.5 right-1.5 z-20 flex items-center gap-0.5 transition-opacity ${showActions ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"}`} ref={menuRef}>276 <button277 onClick={toggleFav}278 className="hidden sm:flex w-8 h-8 items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-amber-500 transition-colors"279 aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}280 >281 <Star className={`w-4 h-4 ${isFavorite ? "text-amber-400 fill-amber-400" : ""}`} />282 </button>283 <button284 onClick={(e) => { e.stopPropagation(); window.open(`/api/files/${file.id}/download`, "_blank"); }}285 className="hidden sm:flex w-8 h-8 items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-blue-600 transition-colors"286 aria-label="Télécharger"287 >288 <Download className="w-4 h-4" />289 </button>290 <div className="relative">291 <button292 onClick={(e) => { e.stopPropagation(); setShowActions(!showActions); }}293 className="w-9 h-9 sm:w-8 sm:h-8 flex items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"294 aria-label="Actions"295 >296 <MoreVertical className="w-4 h-4" />297 </button>298 {showActions && <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} onPreview={() => onPreview(file)} />}299 </div>300 </div>301302 {/* Infos */}303 <div className="p-2.5">304 <p className="text-[13px] leading-tight text-gray-800 dark:text-gray-100 truncate font-medium" title={file.name}>{file.name}</p>305 <p className="text-[11px] text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1 tabular-nums">306 <span>{formatFileSize(file.size)}</span>307 <span className="text-gray-300 dark:text-gray-600">·</span>308 <span className="truncate">{formatRelativeDate(file.updatedAt || file.createdAt)}</span>309 </p>310 {tags.length > 0 && (311 <div className="flex items-center gap-1 mt-1.5 flex-wrap">312 {tags.slice(0, 4).map(({ tag }) => (313 <span key={tag.id} className="w-2.5 h-2.5 rounded-full shrink-0 ring-1 ring-black/5 dark:ring-white/10" style={{ backgroundColor: tag.color }} title={tag.name} />314 ))}315 {tags.length > 4 && <span className="text-[10px] text-gray-400 leading-none">+{tags.length - 4}</span>}316 </div>317 )}318 </div>319 </div>320 );321}322323const FileCard = memo(FileCardInner);324export default FileCard;325