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%
10.4 KB · 256 lines tsx
Raw Blame History
1"use client";23import { useState, useCallback, useEffect } from "react";4import { motion, AnimatePresence, type PanInfo } from "framer-motion";5import { Info, ChevronUp, X } from "lucide-react";6import PreviewToolbar from "./PreviewToolbar";7import PreviewNavigation from "./PreviewNavigation";8import MetadataEditor from "./editors/MetadataEditor";9import VersionHistory from "./VersionHistory";10import { getPlugin } from "./plugins";11import type { FileData } from "@/types/files";12import toast from "react-hot-toast";1314interface PreviewEngineProps {15  file: FileData | null;16  files?: FileData[];17  onClose: () => void;18  onNavigate?: (file: FileData) => void;19  /** Appelé après une modification (favori, version restaurée, sauvegarde) pour rafraîchir la liste parente */20  onChanged?: () => void;21}2223function useCoarsePointer() {24  const [coarse, setCoarse] = useState(false);25  useEffect(() => {26    const mq = window.matchMedia("(pointer: coarse)");27    const upd = () => setCoarse(mq.matches);28    upd();29    mq.addEventListener("change", upd);30    return () => mq.removeEventListener("change", upd);31  }, []);32  return coarse;33}3435export default function PreviewEngine({ file, files = [], onClose, onNavigate, onChanged }: PreviewEngineProps) {36  const [fullscreen, setFullscreen] = useState(false);37  const [isEditing, setIsEditing] = useState(false);38  const [showInfo, setShowInfo] = useState(false);39  const [showMobileInfo, setShowMobileInfo] = useState(false);40  const [isFavorite, setIsFavorite] = useState(!!file?.metadata?.isFavorite);41  const [direction, setDirection] = useState(0);42  const coarse = useCoarsePointer();4344  useEffect(() => { setIsFavorite(!!file?.metadata?.isFavorite); }, [file]);4546  // Préchargement des images voisines47  useEffect(() => {48    if (!file || files.length < 2) return;49    const idx = files.findIndex((f) => f.id === file.id);50    if (idx === -1) return;51    [files[idx - 1], files[idx + 1]]52      .filter((f): f is FileData => !!f && f.mimeType.startsWith("image/"))53      .forEach((f) => { const img = new Image(); img.src = `/api/files/${f.id}/preview?w=2048`; });54  }, [file, files]);5556  // Échap : quitter l'édition / le plein écran / fermer57  useEffect(() => {58    const onKey = (e: KeyboardEvent) => {59      if (e.key !== "Escape") return;60      const t = e.target as HTMLElement | null;61      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;62      if (isEditing) return;63      if (fullscreen) { setFullscreen(false); return; }64      onClose();65    };66    window.addEventListener("keydown", onKey);67    return () => window.removeEventListener("keydown", onKey);68  }, [isEditing, fullscreen, onClose]);6970  // Bloquer le défilement du fond en plein écran71  useEffect(() => {72    if (!fullscreen) return;73    const prev = document.body.style.overflow;74    document.body.style.overflow = "hidden";75    return () => { document.body.style.overflow = prev; };76  }, [fullscreen]);7778  const handleSave = useCallback(79    async (data: Blob | string, asNewVersion?: boolean) => {80      if (!file) return;81      try {82        if (typeof data === "string") {83          const res = await fetch(`/api/files/${file.id}/content`, { method: "PUT", headers: { "Content-Type": "text/plain" }, body: data });84          if (!res.ok) throw new Error("Save failed");85          toast.success("Fichier sauvegardé");86        } else {87          const formData = new FormData();88          formData.append("file", data, file.name);89          if (asNewVersion) formData.append("asNewVersion", "true");90          const res = await fetch(`/api/files/${file.id}/save-edit`, { method: "POST", body: formData });91          if (!res.ok) throw new Error("Save failed");92          toast.success(asNewVersion ? "Nouvelle version créée" : "Fichier sauvegardé");93        }94        onChanged?.();95      } catch {96        toast.error("Erreur lors de la sauvegarde");97      }98    },99    [file, onChanged]100  );101102  const handleToggleFavorite = useCallback(async () => {103    if (!file) return;104    try {105      const res = await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });106      if (res.ok) {107        const meta = await res.json().catch(() => null);108        const fav = typeof meta?.isFavorite === "boolean" ? meta.isFavorite : !isFavorite;109        setIsFavorite(fav);110        toast.success(fav ? "Ajouté aux favoris" : "Retiré des favoris");111        onChanged?.();112      }113    } catch {114      toast.error("Erreur");115    }116  }, [file, isFavorite, onChanged]);117118  if (!file) return null;119120  const plugin = getPlugin(file.mimeType);121  const PluginComponent = plugin.component;122  const idx = files.findIndex((f) => f.id === file.id);123  const canSwipe = coarse && files.length > 1 && !!onNavigate && !isEditing;124125  const navigateTo = (f: FileData, dir: number) => {126    setDirection(dir);127    setIsEditing(false);128    onNavigate?.(f);129  };130131  const onSwipeEnd = (_: unknown, info: PanInfo) => {132    if (!canSwipe) return;133    const threshold = 70;134    if ((info.offset.x < -threshold || info.velocity.x < -500) && idx < files.length - 1) navigateTo(files[idx + 1], 1);135    else if ((info.offset.x > threshold || info.velocity.x > 500) && idx > 0) navigateTo(files[idx - 1], -1);136  };137138  const infoPanel = (139    <>140      <MetadataEditor file={file} onUpdate={onChanged} />141      <VersionHistory fileId={file.id} onRestore={onChanged} />142    </>143  );144145  return (146    <AnimatePresence>147      <motion.div148        key="preview-root"149        initial={{ opacity: 0 }}150        animate={{ opacity: 1 }}151        exit={{ opacity: 0 }}152        transition={{ duration: 0.16 }}153        className={`flex flex-col bg-canvas transition-colors ${154          fullscreen ? "fixed inset-0 z-[60]" : "fixed inset-0 z-50 lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-full"155        }`}156        role="dialog"157        aria-label={`Aperçu : ${file.name}`}158      >159        <PreviewToolbar160          file={file}161          fullscreen={fullscreen}162          isEditing={isEditing}163          supportsEdit={!!plugin.supportsEdit}164          onClose={onClose}165          onToggleFullscreen={() => setFullscreen(!fullscreen)}166          onToggleEdit={() => setIsEditing(!isEditing)}167          onToggleFavorite={handleToggleFavorite}168          isFavorite={isFavorite}169          onToggleInfo={fullscreen ? () => setShowInfo(!showInfo) : undefined}170          infoOpen={showInfo}171          position={files.length > 1 && idx >= 0 ? { index: idx + 1, total: files.length } : undefined}172        />173174        <div className="flex-1 flex overflow-hidden min-h-0">175          {/* Zone principale */}176          <div className="flex-1 relative overflow-hidden min-w-0">177            <AnimatePresence mode="wait" initial={false} custom={direction}>178              <motion.div179                key={file.id}180                custom={direction}181                initial={{ opacity: 0, x: direction * 40 }}182                animate={{ opacity: 1, x: 0 }}183                exit={{ opacity: 0, x: direction * -40 }}184                transition={{ duration: 0.18, ease: [0.2, 0.8, 0.2, 1] }}185                className="absolute inset-0"186                drag={canSwipe ? "x" : false}187                dragConstraints={{ left: 0, right: 0 }}188                dragElastic={0.12}189                dragDirectionLock190                onDragEnd={onSwipeEnd}191              >192                <PluginComponent file={file} isEditing={isEditing} onSave={handleSave} fullscreen={fullscreen} />193              </motion.div>194            </AnimatePresence>195196            {files.length > 1 && onNavigate && !isEditing && (197              <PreviewNavigation files={files} currentFile={file} onNavigate={(f) => navigateTo(f, files.indexOf(f) > idx ? 1 : -1)} />198            )}199200            {/* Bouton fermer flottant (mobile plein écran, si la barre est masquée par un plugin) */}201            {fullscreen && (202              <button onClick={() => setFullscreen(false)} className="lg:hidden absolute top-3 right-3 z-20 w-10 h-10 rounded-full glass flex items-center justify-center text-gray-700 dark:text-gray-200" aria-label="Quitter le plein écran">203                <X className="w-5 h-5" />204              </button>205            )}206          </div>207208          {/* Panneau infos desktop en plein écran */}209          <AnimatePresence initial={false}>210            {fullscreen && showInfo && (211              <motion.aside212                initial={{ width: 0, opacity: 0 }}213                animate={{ width: 320, opacity: 1 }}214                exit={{ width: 0, opacity: 0 }}215                transition={{ type: "spring", stiffness: 380, damping: 36 }}216                className="hidden lg:block shrink-0 overflow-hidden border-l border-line dark:border-white/[0.07] bg-surface dark:bg-canvas"217              >218                <div className="w-[320px] h-full overflow-auto">{infoPanel}</div>219              </motion.aside>220            )}221          </AnimatePresence>222        </div>223224        {/* Mobile : bascule infos */}225        {!fullscreen && (226          <div className="lg:hidden border-t border-line dark:border-white/[0.07] bg-surface/80 dark:bg-canvas/80 backdrop-blur pb-safe shrink-0">227            <button228              onClick={() => setShowMobileInfo(!showMobileInfo)}229              className="w-full flex items-center justify-center gap-2 h-11 text-[13px] text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"230              aria-expanded={showMobileInfo}231            >232              <Info className="w-4 h-4" />233              <span>{showMobileInfo ? "Masquer les infos" : "Infos, tags & versions"}</span>234              <ChevronUp className={`w-4 h-4 transition-transform ${showMobileInfo ? "rotate-180" : ""}`} />235            </button>236            <AnimatePresence initial={false}>237              {showMobileInfo && (238                <motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">239                  <div className="max-h-[45dvh] overflow-auto border-t border-line dark:border-white/[0.07]">{infoPanel}</div>240                </motion.div>241              )}242            </AnimatePresence>243          </div>244        )}245246        {/* Desktop, panneau latéral (non plein écran) : infos sous l'aperçu */}247        {!fullscreen && (248          <div className="hidden lg:block border-t border-line dark:border-white/[0.07] overflow-auto max-h-[45%] shrink-0 bg-surface/60 dark:bg-transparent">249            {infoPanel}250          </div>251        )}252      </motion.div>253    </AnimatePresence>254  );255}256