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