SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
3 h agolast push
JavaScript 56.7% TypeScript 42.6%
14.8 KB · 325 lines tsx
Raw Blame History
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