Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef, useCallback, memo, type ReactNode } from "react"; | |
| import { vfsAsync, onVfsChanged } from "@/lib/vfsDb"; | |
| import type { VFSFile } from "@/lib/vfsDb"; | |
| import { Z_INDEX } from "@/lib/zindex"; | |
| import { parseFile } from "@/lib/fileReader"; | |
| import { | |
| analyzeSync, getImageDimensions, analyzeImageWithAI, | |
| FileAnalysis, FileCategory, | |
| } from "@/lib/fileAnalyzer"; | |
| import { convertFile, downloadBlob, ConvertOptions } from "@/lib/fileConverter"; | |
| import { listSnapshots, restoreSnapshot, deleteSnapshot } from "@/lib/workspaceSnapshot"; // S194 | |
| import type { StoredSnapshot } from "@/lib/vfsDb"; // S194 — StoredSnapshot is exported from vfsDb | |
| // ─── Helpers ────────────────────────────────────────────────────────────────── | |
| const fmt = { | |
| size: (b: number) => | |
| b < 1024 ? `${b} B` : b < 1_048_576 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1_048_576).toFixed(2)} MB`, | |
| date: (ts: number) => | |
| new Date(ts).toLocaleString("it-IT", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }), | |
| num: (n: number) => n.toLocaleString("it-IT"), | |
| }; | |
| const CAT_COLOR: Record<FileCategory, string> = { | |
| image: "#a78bfa", video: "#f59e0b", audio: "#60a5fa", pdf: "#ef4444", | |
| json: "#f97316", csv: "#3b82f6", markdown: "#60a5fa", html: "#facc15", | |
| css: "#38bdf8", code: "#4f8ef7", text: "#9ca3af", archive: "#8b5cf6", | |
| binary: "#6b7280", | |
| }; | |
| const TYPE_EMOJI: Record<FileCategory, string> = { | |
| image: "IMG", video: "VID", audio: "AUD", pdf: "PDF", | |
| json: "JSON", csv: "CSV", markdown:"MD", html: "HTML", | |
| css: "CSS", code: "CODE", text: "TXT", archive:"ZIP", | |
| binary: "BIN", | |
| }; | |
| function getCatFromFile(f: VFSFile): FileCategory { | |
| const ext = f.name.split(".").pop()?.toLowerCase() ?? ""; | |
| const mime = f.type ?? ""; | |
| if (mime.startsWith("image/") || /^(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(ext)) return "image"; | |
| if (mime.startsWith("video/") || /^(mp4|webm|mov)$/.test(ext)) return "video"; | |
| if (mime.startsWith("audio/") || /^(mp3|wav|ogg|m4a|aac)$/.test(ext)) return "audio"; | |
| if (ext === "pdf") return "pdf"; | |
| if (ext === "json") return "json"; | |
| if (ext === "csv") return "csv"; | |
| if (ext === "md") return "markdown"; | |
| if (/^(html?)$/.test(ext)) return "html"; | |
| if (/^(css|scss)$/.test(ext)) return "css"; | |
| if (/^(js|jsx|ts|tsx|py|java|c|cpp|go|rs|rb|php|swift|sh|sql)$/.test(ext)) return "code"; | |
| if (mime.startsWith("text/") || /^(txt|log|yaml|yml|toml|xml|ini|env)$/.test(ext)) return "text"; | |
| if (/^(zip|tar|gz|rar)$/.test(ext)) return "archive"; | |
| return "binary"; | |
| } | |
| // ─── Stat pill ──────────────────────────────────────────────────────────────── | |
| function Pill({ label, value }: { label: string; value: string }) { | |
| return ( | |
| <div style={{ display: "flex", flexDirection: "column", gap: 1, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: "5px 10px", minWidth: 70 }}> | |
| <span style={{ fontSize: "0.58rem", color: "#6b6b8a", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700 }}>{label}</span> | |
| <span style={{ fontSize: "0.78rem", color: "#d0d0f0", fontWeight: 600, fontFamily: "ui-monospace, monospace" }}>{value}</span> | |
| </div> | |
| ); | |
| } | |
| // ─── Action card ────────────────────────────────────────────────────────────── | |
| function ActionCard({ | |
| icon, label, desc, color, active, done, loading, | |
| onClick, | |
| }: { | |
| icon: string; label: string; desc: string; color: string; | |
| active?: boolean; done?: boolean; loading?: boolean; | |
| onClick: () => void; | |
| }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| disabled={loading} | |
| style={{ | |
| all: "unset", cursor: loading ? "default" : "pointer", | |
| display: "flex", alignItems: "center", gap: 10, | |
| padding: "0.65rem 0.9rem", borderRadius: 10, | |
| background: active || done | |
| ? `${color}18` | |
| : "rgba(255,255,255,0.025)", | |
| border: `1px solid ${active || done ? color + "44" : "rgba(255,255,255,0.07)"}`, | |
| transition: "all 0.15s", | |
| opacity: loading ? 0.6 : 1, | |
| }} | |
| onMouseEnter={e => { if (!loading) { e.currentTarget.style.background = `${color}12`; e.currentTarget.style.borderColor = `${color}33`; } }} | |
| onMouseLeave={e => { if (!loading && !active && !done) { e.currentTarget.style.background = "rgba(255,255,255,0.025)"; e.currentTarget.style.borderColor = "rgba(255,255,255,0.07)"; } }} | |
| > | |
| <span style={{ width: 8, height: 8, borderRadius: "50%", background: done ? "#60a5fa" : loading ? "#fbbf24" : color, display: "inline-block", flexShrink: 0 }} /> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: "0.78rem", fontWeight: 600, color: done ? "#60a5fa" : color }}>{done ? "Completato!" : label}</div> | |
| {desc && <div style={{ fontSize: "0.66rem", color: "#5a5a78", marginTop: 1 }}>{desc}</div>} | |
| </div> | |
| </button> | |
| ); | |
| } | |
| // ─── Main component ─────────────────────────────────────────────────────────── | |
| interface FileExplorerProps { | |
| onClose: () => void; | |
| } | |
| const FileExplorer = memo(function FileExplorer({ onClose }: FileExplorerProps) { | |
| const [files, setFiles] = useState<VFSFile[]>([]); | |
| const [selected, setSelected] = useState<VFSFile | null>(null); | |
| const [content, setContent] = useState<string>(""); | |
| const [search, setSearch] = useState(""); | |
| const [contentSearch, setContentSearch] = useState(""); | |
| const [exporting, setExporting] = useState(false); | |
| const [uploading, setUploading] = useState(false); | |
| const [dragOver, setDragOver] = useState(false); | |
| const [analysis, setAnalysis] = useState<FileAnalysis | null>(null); | |
| const [activeAction, setActiveAction] = useState<string | null>(null); | |
| const [actionDone, setActionDone] = useState<string | null>(null); | |
| const [actionError, setActionError] = useState<string | null>(null); | |
| const [opfsLoading, setOpfsLoading] = useState(false); | |
| const [opfsError, setOpfsError] = useState<string | null>(null); | |
| const [opfsRetry, setOpfsRetry] = useState(0); | |
| const [opfsRetryTrig,setOpfsRetryTrig]= useState(0); | |
| const [converting, setConverting] = useState(false); | |
| const [convOpts, setConvOpts] = useState<ConvertOptions>({ quality: 85, maxW: 1280, maxH: 1280 }); | |
| const [tab, setTab] = useState<"files" | "snapshots">("files"); // S194 | |
| const [snapshots, setSnapshots] = useState<StoredSnapshot[]>([]); // S194 | |
| const [snapLoading, setSnapLoading] = useState(false); // S194 | |
| const [snapMsg, setSnapMsg] = useState<string | null>(null); // S194 | |
| const uploadRef = useRef<HTMLInputElement>(null); | |
| const opfsAbortRef = useRef<{ cancelled: boolean }>({ cancelled: false }); | |
| // ── File list ────────────────────────────────────────────────────────────── | |
| const reload = useCallback(() => { | |
| vfsAsync.list().then(setFiles).catch(() => setFiles([])); | |
| }, []); | |
| useEffect(() => { reload(); return onVfsChanged(reload); }, [reload]); | |
| // S194: load snapshots when switching to snapshots tab | |
| useEffect(() => { | |
| if (tab !== "snapshots") return; | |
| setSnapLoading(true); | |
| listSnapshots().then(s => { setSnapshots(s); setSnapLoading(false); }).catch(() => setSnapLoading(false)); | |
| }, [tab]); | |
| // ── Load content when file selected ─────────────────────────────────────── | |
| useEffect(() => { | |
| if (!selected) { opfsAbortRef.current.cancelled = true; setContent(""); setAnalysis(null); setActiveAction(null); setActionDone(null); setOpfsLoading(false); setOpfsError(null); setOpfsRetry(0); return; } | |
| setActiveAction(null); setActionDone(null); setActionError(null); setOpfsLoading(false); setOpfsError(null); setOpfsRetry(0); | |
| if (selected.opfs) { | |
| opfsAbortRef.current.cancelled = true; // cancel any in-flight read | |
| const _opfsToken = { cancelled: false }; | |
| opfsAbortRef.current = _opfsToken; | |
| setContent(""); | |
| setOpfsLoading(true); | |
| setOpfsError(null); | |
| setOpfsRetry(0); | |
| const MAX_OPFS_RETRIES = 3; | |
| const OPFS_TIMEOUT_MS = 8_000; | |
| const opfsDelay = (ms: number) => new Promise<void>(r => setTimeout(r, ms)); | |
| const withTimeout = <T,>(p: Promise<T>, ms: number): Promise<T> => | |
| Promise.race([ | |
| p, | |
| new Promise<T>((_, reject) => | |
| setTimeout(() => reject(new Error('opfs-timeout')), ms), | |
| ), | |
| ]); | |
| (async () => { | |
| for (let attempt = 0; attempt < MAX_OPFS_RETRIES; attempt++) { | |
| if (_opfsToken.cancelled) return; // file changed — abort | |
| setOpfsRetry(attempt + 1); | |
| try { | |
| const c = await withTimeout(vfsAsync.read(selected.path), OPFS_TIMEOUT_MS); | |
| if (_opfsToken.cancelled) return; // file changed mid-read | |
| const c2 = c ?? ""; | |
| setContent(c2); | |
| setAnalysis(analyzeSync(selected, c2)); | |
| setOpfsLoading(false); | |
| return; | |
| } catch (err: unknown) { | |
| const isTimeout = err instanceof Error && err.message === 'opfs-timeout'; | |
| if (attempt < MAX_OPFS_RETRIES - 1) { | |
| await opfsDelay(isTimeout ? 200 : 400 * Math.pow(2, attempt)); | |
| } | |
| } | |
| } | |
| if (!_opfsToken.cancelled) { | |
| setOpfsError(`Impossibile leggere il file dopo ${MAX_OPFS_RETRIES} tentativi (timeout ${OPFS_TIMEOUT_MS / 1000}s/tentativo). Verifica l'archiviazione o ricarica.`); | |
| setOpfsLoading(false); | |
| } | |
| })(); | |
| } else { | |
| setContent(selected.content); | |
| setAnalysis(analyzeSync(selected, selected.content)); | |
| } | |
| }, [selected, opfsRetryTrig]); | |
| // ── Image dimensions (async after analysis) ──────────────────────────────── | |
| useEffect(() => { | |
| if (!selected || !analysis) return; | |
| if (analysis.category !== "image") return; | |
| const src = content || selected.content; | |
| if (!src) return; | |
| getImageDimensions(src).then(({ w, h }) => { | |
| if (w > 0) setAnalysis(prev => prev ? { ...prev, imgWidth: w, imgHeight: h } : prev); | |
| }); | |
| }, [selected, analysis?.category, content]); // eslint-disable-line react-hooks/exhaustive-deps | |
| // ── AI analysis ──────────────────────────────────────────────────────────── | |
| const runAIAnalysis = useCallback(async () => { | |
| if (!selected || !analysis) return; | |
| setAnalysis(prev => prev ? { ...prev, aiAnalyzing: true, aiError: undefined } : prev); | |
| const src = content || selected.content; | |
| const result = await analyzeImageWithAI(src, selected.name); | |
| setAnalysis(prev => prev ? { ...prev, aiAnalyzing: false, ...result } : prev); | |
| }, [selected, analysis, content]); | |
| // ── Manual OPFS retry ───────────────────────────────────────────────────── | |
| const retryOpfsRead = useCallback(() => { | |
| setOpfsRetryTrig(t => t + 1); | |
| }, []); | |
| // ── Conversion ───────────────────────────────────────────────────────────── | |
| const runConvert = useCallback(async (action: string) => { | |
| if (!selected) return; | |
| if (opfsLoading) return; // guard: OPFS still loading | |
| if (action === "ai-analyze") { void runAIAnalysis(); return; } | |
| if (action === "copy") { | |
| await navigator.clipboard.writeText(content || selected.content).catch(() => {}); | |
| setActionDone(action); | |
| setTimeout(() => setActionDone(null), 2000); | |
| return; | |
| } | |
| if (action === "download") { | |
| const c2 = content || selected.content; | |
| const blob = new Blob([c2], { type: selected.type || "text/plain" }); | |
| downloadBlob(blob, selected.name); | |
| return; | |
| } | |
| if (action === "delete") { | |
| await vfsAsync.delete(selected.path); | |
| setSelected(null); | |
| reload(); | |
| return; | |
| } | |
| setConverting(true); | |
| setActionError(null); | |
| try { | |
| const c2 = content || selected.content; | |
| const res = await convertFile(selected, c2, action, convOpts); | |
| downloadBlob(res.blob, res.filename); | |
| setActionDone(action); | |
| setTimeout(() => setActionDone(null), 3000); | |
| } catch (e: unknown) { | |
| setActionError(e instanceof Error ? e.message : "Errore conversione"); | |
| } finally { | |
| setConverting(false); | |
| } | |
| }, [selected, content, convOpts, reload, runAIAnalysis]); | |
| // ── Upload ───────────────────────────────────────────────────────────────── | |
| const handleUpload = useCallback(async (fileList: FileList | null) => { | |
| if (!fileList || fileList.length === 0) return; | |
| setUploading(true); | |
| try { | |
| for (const file of Array.from(fileList)) { | |
| const parsed = await parseFile(file); | |
| if (parsed.error) continue; | |
| if (parsed.category === "image" && parsed.dataUrl) { | |
| await vfsAsync.write("/uploads/" + file.name, parsed.dataUrl, file.type); | |
| } else if (parsed.content) { | |
| await vfsAsync.write("/uploads/" + file.name, parsed.content, file.type || "text/plain"); | |
| // S459: RAG indexing — popola ragPipeline sui file caricati nel VFS (fire-and-forget) | |
| import("@/lib/agent/ragPipeline").then(m => m.indexParsedFile(parsed)).catch(() => {}); | |
| } | |
| } | |
| reload(); | |
| } finally { | |
| setUploading(false); | |
| if (uploadRef.current) uploadRef.current.value = ""; | |
| } | |
| }, [reload]); | |
| // ── Export ZIP ──────────────────────────────────────────────────────────── | |
| const exportAllZip = useCallback(async () => { | |
| if (files.length === 0) return; | |
| setExporting(true); | |
| try { | |
| const JSZip = (await import("jszip")).default; | |
| const zip = new JSZip(); | |
| for (const f of files) { | |
| const c = f.opfs ? (await vfsAsync.read(f.path) ?? "") : f.content; | |
| zip.file(f.path.startsWith("/") ? f.path.slice(1) : f.path, c); | |
| } | |
| const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); | |
| downloadBlob(blob, "vfs-export.zip"); | |
| } finally { | |
| setExporting(false); | |
| } | |
| }, [files]); | |
| // ── Download single file ────────────────────────────────────────────────── | |
| const downloadFile = useCallback(async (f: VFSFile) => { | |
| const c = f.opfs ? (await vfsAsync.read(f.path) ?? "") : f.content; | |
| const blob = new Blob([c], { type: f.type || "text/plain" }); | |
| downloadBlob(blob, f.name); | |
| }, []); | |
| // ── Filtered file list ──────────────────────────────────────────────────── | |
| const filtered = search.trim() | |
| ? files.filter(f => | |
| f.path.toLowerCase().includes(search.toLowerCase()) || | |
| (!f.opfs && f.content.toLowerCase().includes(search.toLowerCase())) | |
| ) | |
| : files; | |
| const totalBytes = files.reduce((s, f) => s + f.size, 0); | |
| // ── Select file ─────────────────────────────────────────────────────────── | |
| const selectFile = useCallback((f: VFSFile) => { | |
| setSelected(prev => prev?.path === f.path ? null : f); | |
| setContentSearch(""); | |
| }, []); | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Render | |
| // ───────────────────────────────────────────────────────────────────────── | |
| const showPanel = !!selected; | |
| const listWidth = showPanel ? "min(300px, 42%)" : "100%"; | |
| return ( | |
| <div | |
| style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL_OVERLAY, display: "flex", background: "rgba(0,0,0,0.65)", alignItems: "center", justifyContent: "center" }} | |
| onClick={onClose} | |
| > | |
| <div | |
| style={{ background: "rgba(7,7,18,0.94)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 18, width: "min(920px, 97vw)", maxHeight: "88vh", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 24px 80px rgba(0,0,0,0.75)" }} | |
| onClick={e => e.stopPropagation()} | |
| > | |
| {/* ── Header ── */} | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.85rem 1.2rem", borderBottom: "1px solid rgba(255,255,255,0.07)", gap: 8, flexWrap: "wrap" }}> | |
| <div> | |
| <div style={{ fontWeight: 700, fontSize: "0.97rem", color: "#e2e2f0" }}>File Manager VFS</div> | |
| <div style={{ fontSize: "0.68rem", color: "#6b6b7b", marginTop: 1 }}>{files.length} file · {fmt.size(totalBytes)}</div> | |
| </div> | |
| <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}> | |
| <input ref={uploadRef} type="file" multiple style={{ display: "none" }} onChange={e => handleUpload(e.target.files)} /> | |
| <Btn color="#4f8ef7" onClick={() => uploadRef.current?.click()} disabled={uploading}>{uploading ? "Carico…" : "Carica"}</Btn> | |
| {files.length > 0 && <Btn color="#a78bfa" onClick={exportAllZip} disabled={exporting}>{exporting ? "ZIP…" : "Esporta ZIP"}</Btn>} | |
| {files.length > 0 && <Btn color="#ef4444" onClick={() => { vfsAsync.clear().catch(() => {}); setSelected(null); reload(); }}>Svuota</Btn>} | |
| <button onClick={onClose} style={{ all: "unset", cursor: "pointer", color: "#6b6b7b", fontSize: "1.15rem", padding: "0 4px" }}>✕</button> | |
| </div> | |
| </div> | |
| {/* ── Tab bar ── */} | |
| <div style={{ display: "flex", gap: 2, padding: "0.4rem 1.2rem 0", borderBottom: "1px solid rgba(255,255,255,0.07)" }}> | |
| {(["files", "snapshots"] as const).map(t => ( | |
| <button key={t} onClick={() => setTab(t)} style={{ | |
| all: "unset", cursor: "pointer", padding: "0.3rem 0.8rem", borderRadius: "7px 7px 0 0", | |
| fontSize: "0.72rem", fontWeight: tab === t ? 700 : 400, | |
| color: tab === t ? "#3b82f6" : "#6b6b8a", | |
| background: tab === t ? "rgba(59,130,246,0.1)" : "transparent", | |
| transition: "all 0.15s", | |
| }}> | |
| {t === "files" ? "Files" : "Snapshots"} | |
| </button> | |
| ))} | |
| </div> | |
| {/* ── Body ── */} | |
| <div style={{ display: tab === "files" ? "flex" : "none", flex: 1, overflow: "hidden", minHeight: 0 }}> | |
| {/* ── File list ── */} | |
| <div | |
| style={{ width: listWidth, flexShrink: 0, borderRight: showPanel ? "1px solid rgba(255,255,255,0.07)" : "none", display: "flex", flexDirection: "column", overflow: "hidden", transition: "width 0.2s", background: dragOver ? "rgba(79,142,247,0.04)" : "transparent" }} | |
| onDragOver={e => { e.preventDefault(); setDragOver(true); }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={e => { e.preventDefault(); setDragOver(false); handleUpload(e.dataTransfer.files); }} | |
| > | |
| <div style={{ padding: "0.5rem 0.8rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}> | |
| <input | |
| value={search} | |
| onChange={e => setSearch(e.target.value)} | |
| placeholder="Cerca nome o contenuto…" | |
| style={{ width: "100%", boxSizing: "border-box", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, padding: "0.3rem 0.6rem", color: "#e2e2f0", fontSize: "0.76rem", outline: "none" }} | |
| /> | |
| </div> | |
| <div style={{ flex: 1, overflowY: "auto" }}> | |
| {filtered.length === 0 ? ( | |
| <div style={{ textAlign: "center", color: "#4a4a62", padding: "2.5rem 1rem" }}> | |
| <div style={{ width: 40, height: 40, borderRadius: 8, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", marginBottom: 8, display: "flex", alignItems: "center", justifyContent: "center" }}> | |
| <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="#4a4a72" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> | |
| </div> | |
| <div style={{ fontSize: "0.82rem" }}>{search ? "Nessun risultato" : "VFS vuoto"}</div> | |
| {!search && <div style={{ fontSize: "0.7rem", marginTop: 5, color: "#3a3a52" }}>Trascina file qui o usa Carica</div>} | |
| </div> | |
| ) : filtered.map(f => { | |
| const cat = getCatFromFile(f); | |
| const isSel = selected?.path === f.path; | |
| const matchesContent = search.trim() && !f.opfs && f.content.toLowerCase().includes(search.toLowerCase()) && !f.path.toLowerCase().includes(search.toLowerCase()); | |
| return ( | |
| <div | |
| key={f.path} | |
| onClick={() => selectFile(f)} | |
| style={{ display: "flex", alignItems: "center", gap: 8, padding: "0.42rem 0.8rem", cursor: "pointer", background: isSel ? "rgba(79,142,247,0.09)" : "transparent", borderBottom: "1px solid rgba(255,255,255,0.025)", borderLeft: isSel ? "2px solid #4f8ef7" : "2px solid transparent", transition: "all 0.1s" }} | |
| > | |
| <span style={{ fontSize: "0.58rem", fontWeight: 700, color: CAT_COLOR[cat], background: `${CAT_COLOR[cat]}18`, border: `1px solid ${CAT_COLOR[cat]}33`, borderRadius: 3, padding: "0 3px", letterSpacing: "0.03em", flexShrink: 0 }}>{TYPE_EMOJI[cat]}</span> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: "0.78rem", color: "#c4c4d8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.name}</div> | |
| <div style={{ fontSize: "0.63rem", color: "#5a5a72", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {matchesContent ? <span style={{ color: "#f59e0b" }}>Match nel contenuto</span> : f.path} | |
| </div> | |
| </div> | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 1, flexShrink: 0 }}> | |
| <span style={{ fontSize: "0.63rem", color: "#6b6b8a", fontFamily: "ui-monospace, monospace" }}>{fmt.size(f.size)}</span> | |
| <span style={{ fontSize: "0.58rem", color: CAT_COLOR[cat], background: `${CAT_COLOR[cat]}18`, border: `1px solid ${CAT_COLOR[cat]}33`, borderRadius: 4, padding: "0 4px", lineHeight: 1.5 }}>{cat}</span> | |
| </div> | |
| <button onClick={e => { e.stopPropagation(); void downloadFile(f); }} style={{ all: "unset", cursor: "pointer", color: "#4a4a72", fontSize: "0.8rem", padding: "2px 3px" }}>⬇</button> | |
| <button onClick={e => { e.stopPropagation(); void vfsAsync.delete(f.path).then(reload); if (selected?.path === f.path) setSelected(null); }} style={{ all: "unset", cursor: "pointer", color: "#4a4a72", fontSize: "0.72rem", padding: "2px 3px" }}>X</button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {dragOver && ( | |
| <div style={{ padding: "0.5rem", borderTop: "1px solid rgba(79,142,247,0.3)", background: "rgba(79,142,247,0.07)", textAlign: "center", fontSize: "0.73rem", color: "#4f8ef7" }}> | |
| Rilascia per caricare | |
| </div> | |
| )} | |
| </div> | |
| {/* ── Detail panel ── */} | |
| {showPanel && selected && ( | |
| <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}> | |
| {/* Detail header */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0.6rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.07)", flexShrink: 0 }}> | |
| <span style={{ fontSize: "0.58rem", fontWeight: 700, color: analysis ? CAT_COLOR[analysis.category] : "#9090c0", background: analysis ? `${CAT_COLOR[analysis.category]}18` : "rgba(144,144,192,0.12)", border: `1px solid ${analysis ? CAT_COLOR[analysis.category] + "33" : "rgba(144,144,192,0.2)"}`, borderRadius: 3, padding: "0 3px", letterSpacing: "0.03em", flexShrink: 0 }}>{analysis ? TYPE_EMOJI[analysis.category] : "FILE"}</span> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#e0e0f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{selected.name}</div> | |
| <div style={{ fontSize: "0.64rem", color: "#6b6b8a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{selected.path} · {fmt.date(selected.updatedAt)}</div> | |
| </div> | |
| {analysis && ( | |
| <span style={{ fontSize: "0.65rem", color: CAT_COLOR[analysis.category], background: `${CAT_COLOR[analysis.category]}18`, border: `1px solid ${CAT_COLOR[analysis.category]}33`, borderRadius: 5, padding: "2px 7px", flexShrink: 0, fontWeight: 700 }}> | |
| {analysis.language ?? analysis.category.toUpperCase()} | |
| </span> | |
| )} | |
| <button onClick={() => setSelected(null)} style={{ all: "unset", cursor: "pointer", color: "#6b6b7b", fontSize: "0.95rem", padding: "2px 4px" }}>✕</button> | |
| </div> | |
| {/* Scrollable detail body */} | |
| <div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 0 }}> | |
| {/* ── Preview ── */} | |
| <div style={{ flexShrink: 0 }}> | |
| {analysis?.category === "image" && ( | |
| <div style={{ background: "rgba(255,255,255,0.02)", borderBottom: "1px solid rgba(255,255,255,0.05)", display: "flex", alignItems: "center", justifyContent: "center", padding: "0.8rem", minHeight: 140, maxHeight: 260 }}> | |
| <img | |
| src={content || selected.content} | |
| alt={selected.name} | |
| style={{ maxWidth: "100%", maxHeight: 220, objectFit: "contain", borderRadius: 8, boxShadow: "0 4px 20px rgba(0,0,0,0.4)" }} | |
| /> | |
| </div> | |
| )} | |
| {analysis?.category === "audio" && ( | |
| <div style={{ padding: "0.8rem 1rem", background: "rgba(96,165,250,0.04)", borderBottom: "1px solid rgba(255,255,255,0.05)" }}> | |
| <audio controls style={{ width: "100%", height: 40 }} src={content || selected.content} /> | |
| </div> | |
| )} | |
| {analysis?.category === "video" && ( | |
| <div style={{ padding: "0.8rem 1rem", background: "rgba(245,158,11,0.04)", borderBottom: "1px solid rgba(255,255,255,0.05)" }}> | |
| <video controls style={{ width: "100%", maxHeight: 200, borderRadius: 8 }} src={content || selected.content} /> | |
| </div> | |
| )} | |
| {analysis?.category === "csv" && analysis.csvHeaders && ( | |
| <div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", overflowX: "auto" }}> | |
| <div style={{ fontSize: "0.62rem", color: "#6b6b8a", marginBottom: 4 }}>Anteprima tabella (prime 5 righe)</div> | |
| <table style={{ borderCollapse: "collapse", fontSize: "0.71rem", color: "#c0c0d0", minWidth: "100%" }}> | |
| <thead> | |
| <tr>{analysis.csvHeaders.map(h => ( | |
| <th key={h} style={{ borderBottom: "1px solid rgba(255,255,255,0.12)", padding: "4px 10px", textAlign: "left", color: "#3b82f6", fontWeight: 700, whiteSpace: "nowrap" }}>{h}</th> | |
| ))}</tr> | |
| </thead> | |
| <tbody> | |
| {(analysis.csvPreview ?? []).map((row, i) => ( | |
| <tr key={i} style={{ background: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.015)" }}> | |
| {row.map((cell, j) => ( | |
| <td key={j} style={{ padding: "3px 10px", borderBottom: "1px solid rgba(255,255,255,0.04)", whiteSpace: "nowrap", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis" }}>{cell}</td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {analysis?.category === "html" && content && ( | |
| <div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}> | |
| <div style={{ fontSize: "0.62rem", color: "#6b6b8a", marginBottom: 4 }}>Anteprima HTML</div> | |
| <iframe | |
| srcDoc={content} | |
| sandbox="allow-same-origin" | |
| style={{ width: "100%", height: 180, border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, background: "#fff" }} | |
| title={selected.name} | |
| /> | |
| </div> | |
| )} | |
| {(analysis?.category === "text" || analysis?.category === "code" || analysis?.category === "markdown" || analysis?.category === "css" || analysis?.category === "json") && content && ( | |
| <div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}> | |
| <div style={{ fontSize: "0.62rem", color: "#6b6b8a" }}>Anteprima</div> | |
| {analysis.category === "json" && ( | |
| <span style={{ fontSize: "0.6rem", color: analysis.jsonValid ? "#60a5fa" : "#ef4444", background: analysis.jsonValid ? "rgba(96,165,250,0.1)" : "rgba(239,68,68,0.1)", border: `1px solid ${analysis.jsonValid ? "rgba(96,165,250,0.3)" : "rgba(239,68,68,0.3)"}`, borderRadius: 4, padding: "1px 6px" }}> | |
| {analysis.jsonValid ? "ok JSON valido" : "! JSON non valido"} | |
| </span> | |
| )} | |
| </div> | |
| <div style={{ position: "relative" }}> | |
| {contentSearch && ( | |
| <div style={{ marginBottom: 4 }}> | |
| <input | |
| value={contentSearch} | |
| onChange={e => setContentSearch(e.target.value)} | |
| placeholder="Cerca nel contenuto…" | |
| style={{ width: "100%", boxSizing: "border-box", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 5, padding: "0.2rem 0.5rem", color: "#e2e2f0", fontSize: "0.72rem", outline: "none" }} | |
| /> | |
| </div> | |
| )} | |
| {opfsLoading ? ( | |
| <div style={{ padding: "1.2rem 0.7rem", background: "rgba(0,0,0,0.25)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.06)", display: "flex", alignItems: "center", gap: 8, color: "#6b7280", fontSize: "0.74rem" }}> | |
| <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4f8ef7", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite" }} /> | |
| Lettura in corso… | |
| </div> | |
| ) : opfsError ? ( | |
| <div style={{ padding: "1rem 0.7rem", background: "rgba(239,68,68,0.05)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.12)", color: "#fca5a5", fontSize: "0.73rem" }}> | |
| {opfsError} | |
| </div> | |
| ) : ( | |
| <pre style={{ margin: 0, padding: "0.7rem", background: "rgba(0,0,0,0.25)", borderRadius: 8, fontSize: "0.71rem", lineHeight: 1.55, color: "#c8c8de", fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 200, overflow: "auto", border: "1px solid rgba(255,255,255,0.06)" }}> | |
| {contentSearch.trim() | |
| ? content.split("\n").filter(l => l.toLowerCase().includes(contentSearch.toLowerCase())).slice(0, 30).join("\n") || "(Nessun match)" | |
| : content.slice(0, 2000) + (content.length > 2000 ? `\n… (${fmt.num(content.length - 2000)} caratteri in più)` : "") | |
| } | |
| </pre> | |
| )} | |
| {!opfsLoading && !opfsError && !contentSearch && analysis.category !== "json" && ( | |
| <button onClick={() => setContentSearch(" ")} style={{ all: "unset", cursor: "pointer", position: "absolute", top: 6, right: 6, fontSize: "0.62rem", color: "#6b6b8a", background: "rgba(0,0,0,0.4)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 4, padding: "2px 6px" }}>Cerca</button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* ── OPFS loading banner ── */} | |
| {opfsLoading && ( | |
| <div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", display: "flex", alignItems: "center", gap: 9, background: "rgba(79,142,247,0.06)" }}> | |
| <span style={{ width: 10, height: 10, borderRadius: "50%", background: "#4f8ef7", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite", flexShrink: 0 }} /> | |
| <div> | |
| <div style={{ fontSize: "0.75rem", color: "#93c5fd", fontWeight: 600 }}>Caricamento in corso… {opfsRetry > 1 ? `(tentativo ${opfsRetry}/3)` : ""}</div> | |
| <div style={{ fontSize: "0.66rem", color: "#6b7280", marginTop: 1 }}>File OPFS in lettura — timeout 5s per tentativo. Attendi prima di operare sul file.</div> | |
| </div> | |
| </div> | |
| )} | |
| {/* ── OPFS error banner ── */} | |
| {opfsError && !opfsLoading && ( | |
| <div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid rgba(239,68,68,0.15)", display: "flex", alignItems: "flex-start", gap: 9, background: "rgba(239,68,68,0.06)" }}> | |
| <span style={{ fontSize: "0.85rem", color: "#fbbf24", fontWeight: 700, flexShrink: 0 }}>!</span> | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontSize: "0.75rem", color: "#ef4444", fontWeight: 600 }}>Errore lettura file</div> | |
| <div style={{ fontSize: "0.68rem", color: "#fca5a5", marginTop: 1 }}>{opfsError}</div> | |
| <button | |
| onClick={retryOpfsRead} | |
| style={{ all: "unset", cursor: "pointer", marginTop: 6, display: "inline-block", fontSize: "0.7rem", color: "#93c5fd", background: "rgba(79,142,247,0.12)", border: "1px solid rgba(79,142,247,0.25)", borderRadius: 6, padding: "3px 10px", fontWeight: 600 }} | |
| > | |
| Riprova | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* ── Stats ── */} | |
| {analysis && !opfsLoading && ( | |
| <div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", flexShrink: 0 }}> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> | |
| <Pill label="Formato" value={analysis.category.toUpperCase()} /> | |
| <Pill label="Dimensione" value={fmt.size(selected.size)} /> | |
| {analysis.imgWidth != null && analysis.imgWidth > 0 && <Pill label="Larghezza" value={`${analysis.imgWidth}px`} />} | |
| {analysis.imgHeight != null && analysis.imgHeight > 0 && <Pill label="Altezza" value={`${analysis.imgHeight}px`} />} | |
| {analysis.lineCount != null && <Pill label="Righe" value={fmt.num(analysis.lineCount)} />} | |
| {analysis.wordCount != null && <Pill label="Parole" value={fmt.num(analysis.wordCount)} />} | |
| {analysis.csvRows != null && <Pill label="Righe" value={fmt.num(analysis.csvRows)} />} | |
| {analysis.csvCols != null && <Pill label="Colonne" value={fmt.num(analysis.csvCols)} />} | |
| {analysis.jsonKeyCount != null && <Pill label={analysis.jsonIsArray ? "Elementi" : "Chiavi"} value={fmt.num(analysis.jsonKeyCount)} />} | |
| {analysis.jsonDepth != null && <Pill label="Profondità" value={String(analysis.jsonDepth)} />} | |
| </div> | |
| </div> | |
| )} | |
| {/* ── AI analysis result ── */} | |
| {(analysis?.aiAnalyzing || analysis?.aiDescription || analysis?.aiError) && ( | |
| <div style={{ padding: "0.8rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", flexShrink: 0 }}> | |
| <div style={{ fontSize: "0.66rem", color: "#a78bfa", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700, marginBottom: 6 }}>Analisi AI</div> | |
| {analysis.aiAnalyzing && ( | |
| <div style={{ fontSize: "0.76rem", color: "#a78bfa", display: "flex", alignItems: "center", gap: 6 }}> | |
| <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#a78bfa", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite", flexShrink: 0 }} /> Analisi in corso… | |
| </div> | |
| )} | |
| {analysis.aiError && <div style={{ fontSize: "0.74rem", color: "#ef4444" }}>! {analysis.aiError}</div>} | |
| {analysis.aiDescription && ( | |
| <div style={{ fontSize: "0.76rem", color: "#d0d0f0", lineHeight: 1.5, marginBottom: 6 }}>{analysis.aiDescription}</div> | |
| )} | |
| {analysis.aiMood && ( | |
| <div style={{ fontSize: "0.7rem", color: "#a78bfa" }}>Mood: {analysis.aiMood}</div> | |
| )} | |
| {analysis.aiTextInImage && ( | |
| <div style={{ fontSize: "0.72rem", color: "#fbbf24", marginTop: 4 }}>Testo rilevato: "{analysis.aiTextInImage}"</div> | |
| )} | |
| {analysis.aiObjects && analysis.aiObjects.length > 0 && ( | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 5 }}> | |
| {analysis.aiObjects.map(o => ( | |
| <span key={o} style={{ fontSize: "0.65rem", color: "#60a5fa", background: "rgba(96,165,250,0.08)", border: "1px solid rgba(96,165,250,0.2)", borderRadius: 5, padding: "2px 7px" }}>{o}</span> | |
| ))} | |
| </div> | |
| )} | |
| {analysis.aiTags && analysis.aiTags.length > 0 && ( | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 5 }}> | |
| {analysis.aiTags.map(t => ( | |
| <span key={t} style={{ fontSize: "0.62rem", color: "#9ca3af", background: "rgba(156,163,175,0.08)", border: "1px solid rgba(156,163,175,0.15)", borderRadius: 5, padding: "2px 6px" }}>#{t}</span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* ── Actions ── */} | |
| {analysis && ( | |
| <div style={{ padding: "0.8rem 1rem", flexShrink: 0 }}> | |
| <div style={{ fontSize: "0.62rem", color: "#6b6b8a", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700, marginBottom: 8 }}>Cosa vuoi fare?</div> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 5 }}> | |
| {analysis.actions.map(act => ( | |
| <div key={act.id}> | |
| <ActionCard | |
| icon={act.icon} label={act.label} desc={act.desc} color={act.color} | |
| active={activeAction === act.id} | |
| done={actionDone === act.id} | |
| loading={converting && activeAction === act.id} | |
| onClick={() => { | |
| if (opfsLoading) return; | |
| if (act.hasOptions) { | |
| setActiveAction(prev => prev === act.id ? null : act.id); | |
| setActionDone(null); | |
| } else { | |
| setActiveAction(act.id); | |
| void runConvert(act.id); | |
| } | |
| }} | |
| /> | |
| {/* Inline options for resize/compress */} | |
| {activeAction === act.id && act.hasOptions && ( | |
| <div style={{ marginTop: 5, marginLeft: 12, padding: "0.7rem 0.9rem", background: "rgba(255,255,255,0.03)", border: `1px solid ${act.color}22`, borderRadius: 9, display: "flex", flexDirection: "column", gap: 8 }}> | |
| {act.id === "resize" && ( | |
| <> | |
| <div style={{ display: "flex", gap: 8, alignItems: "center" }}> | |
| <label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 70 }}>Max W (px)</label> | |
| <input type="number" min={64} max={4096} value={convOpts.maxW} onChange={e => setConvOpts(p => ({ ...p, maxW: +e.target.value }))} | |
| style={{ flex: 1, background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 5, padding: "3px 7px", color: "#e2e2f0", fontSize: "0.75rem", outline: "none" }} /> | |
| <label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 70 }}>Max H (px)</label> | |
| <input type="number" min={64} max={4096} value={convOpts.maxH} onChange={e => setConvOpts(p => ({ ...p, maxH: +e.target.value }))} | |
| style={{ flex: 1, background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 5, padding: "3px 7px", color: "#e2e2f0", fontSize: "0.75rem", outline: "none" }} /> | |
| </div> | |
| </> | |
| )} | |
| {act.id === "compress" && ( | |
| <div style={{ display: "flex", gap: 8, alignItems: "center" }}> | |
| <label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 80 }}>Qualità</label> | |
| <input type="range" min={10} max={95} value={convOpts.quality} onChange={e => setConvOpts(p => ({ ...p, quality: +e.target.value }))} style={{ flex: 1 }} /> | |
| <span style={{ fontSize: "0.75rem", color: "#e2e2f0", width: 36, textAlign: "right", fontFamily: "ui-monospace, monospace" }}>{convOpts.quality}%</span> | |
| </div> | |
| )} | |
| <button | |
| onClick={() => void runConvert(act.id)} | |
| disabled={converting} | |
| style={{ all: "unset", cursor: converting ? "default" : "pointer", alignSelf: "flex-start", padding: "0.35rem 1rem", borderRadius: 7, background: act.color, color: "#fff", fontSize: "0.74rem", fontWeight: 700, opacity: converting ? 0.6 : 1, transition: "opacity 0.15s" }} | |
| > | |
| {converting ? "Conversione…" : "Converti e scarica"} | |
| </button> | |
| {actionError && <div style={{ fontSize: "0.7rem", color: "#ef4444" }}>! {actionError}</div>} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| {actionError && !activeAction?.match(/resize|compress/) && ( | |
| <div style={{ marginTop: 6, fontSize: "0.72rem", color: "#ef4444" }}>! {actionError}</div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* ── Snapshots panel ── */} | |
| {tab === "snapshots" && ( | |
| <div style={{ flex: 1, overflow: "auto", padding: "1rem 1.2rem" }}> | |
| {snapMsg && ( | |
| <div style={{ padding: "0.5rem 0.85rem", marginBottom: "0.8rem", borderRadius: 8, | |
| background: "rgba(59,130,246,0.12)", color: "#3b82f6", fontSize: "0.78rem" }}> | |
| {snapMsg} | |
| </div> | |
| )} | |
| {snapLoading ? ( | |
| <div style={{ textAlign: "center", color: "#6b6b8a", padding: "2rem", fontSize: "0.82rem" }}>Caricamento...</div> | |
| ) : snapshots.length === 0 ? ( | |
| <div style={{ textAlign: "center", color: "#4a4a62", padding: "2.5rem 1rem" }}> | |
| <div style={{ fontSize: "2.2rem", marginBottom: 8 }}>...</div> | |
| <div style={{ fontSize: "0.82rem" }}>Nessuno snapshot salvato</div> | |
| <div style={{ fontSize: "0.7rem", marginTop: 5, color: "#3a3a52" }}>Creati automaticamente ogni 3 step</div> | |
| </div> | |
| ) : snapshots.map(s => ( | |
| <div key={s.id} style={{ | |
| display: "flex", alignItems: "center", gap: 10, padding: "0.6rem 0.85rem", | |
| borderRadius: 10, marginBottom: 6, | |
| background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)", | |
| }}> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: "0.8rem", color: "#c0c0d0", fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {s.label || "Snapshot"} | |
| </div> | |
| <div style={{ fontSize: "0.67rem", color: "#6b6b8a", marginTop: 2 }}> | |
| {new Date(s.createdAt).toLocaleString("it-IT", { dateStyle: "short", timeStyle: "short" })} · {(s.files as unknown[])?.length ?? 0} file | |
| </div> | |
| </div> | |
| <Btn color="#3b82f6" onClick={async () => { | |
| const ok = await restoreSnapshot(s.id); | |
| setSnapMsg(ok ? "Snapshot ripristinato" : "Errore ripristino"); | |
| setTimeout(() => setSnapMsg(null), 3000); | |
| }}>Ripristina</Btn> | |
| <Btn color="#ef4444" onClick={async () => { | |
| await deleteSnapshot(s.id); | |
| setSnapshots(prev => prev.filter(x => x.id !== s.id)); | |
| }}>X</Btn> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| export default FileExplorer; | |
| // ─── Mini button helper ─────────────────────────────────────────────────────── | |
| function Btn({ children, color, onClick, disabled }: { | |
| children: ReactNode; color: string; | |
| onClick: () => void; disabled?: boolean; | |
| }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| disabled={disabled} | |
| style={{ all: "unset", cursor: disabled ? "default" : "pointer", fontSize: "0.73rem", color, padding: "3px 9px", borderRadius: 6, background: `${color}12`, border: `1px solid ${color}28`, opacity: disabled ? 0.6 : 1, transition: "opacity 0.15s" }} | |
| > | |
| {children} | |
| </button> | |
| ); | |
| } | |