"use client"; import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Download, Upload, Loader2, AlertTriangle, Archive, PackageOpen } from "lucide-react"; export function BackupButtons() { const router = useRouter(); const fileRef = useRef(null); const zipFileRef = useRef(null); const [importing, setImporting] = useState(false); const [importingZip, setImportingZip] = useState(false); const [exporting, setExporting] = useState(false); const [exportingAll, setExportingAll] = useState(false); const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null); // Synchronous guard: setImporting() updates state asynchronously, so a // rapid second invocation could slip through before importing=true is // visible in the next render. The ref blocks that window. const importingRef = useRef(false); const importingZipRef = useRef(false); async function handleExport() { setExporting(true); setMessage(null); try { const res = await fetch("/api/backup/export"); if (!res.ok) throw new Error(`Export failed (${res.status})`); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); a.download = `pinkudex-backup-${stamp}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setMessage({ kind: "ok", text: "Backup downloaded." }); } catch (e) { setMessage({ kind: "err", text: (e as Error).message }); } finally { setExporting(false); } } async function handleExportAll() { setExportingAll(true); setMessage(null); try { // Direct navigation so the browser owns the download (and shows its // own progress bar, ETA, pause/cancel) — fetch+blob would buffer the // whole zip in memory, which is fatal for multi-GB libraries. const a = document.createElement("a"); a.href = "/api/backup/library-export"; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); setMessage({ kind: "ok", text: "Library export started — see browser downloads." }); } catch (e) { setMessage({ kind: "err", text: (e as Error).message }); } finally { setExportingAll(false); } } async function handleImport(file: File) { if (importingRef.current) return; // Claim the lock BEFORE confirm() so a second invocation triggered // while the dialog is still open (e.g. rapid double-pick of the same // file) hits the early return instead of slipping through the race // window between confirm and setImporting. importingRef.current = true; if (!confirm( "Importing will REPLACE all existing actresses, covers, categories, tags, collections and settings with the contents of this backup.\n\nThis cannot be undone. Continue?", )) { importingRef.current = false; return; } setImporting(true); setMessage(null); try { const text = await file.text(); const res = await fetch("/api/backup/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: text, }); const j = await res.json(); if (!res.ok) throw new Error(j.error ?? `Import failed (${res.status})`); const totals = Object.entries(j.counts ?? {}) .filter(([, n]) => (n as number) > 0) .map(([t, n]) => `${t}: ${n}`) .join(", "); setMessage({ kind: "ok", text: `Import complete. ${totals || "(empty)"}` }); router.refresh(); } catch (e) { setMessage({ kind: "err", text: (e as Error).message }); } finally { importingRef.current = false; setImporting(false); if (fileRef.current) fileRef.current.value = ""; } } async function handleImportZip(file: File) { if (importingZipRef.current) return; // Same race-window fix as handleImport — claim the lock before the // confirm() dialog opens. importingZipRef.current = true; if (!confirm( "Restoring will REPLACE the database AND your library/, data/thumbs/, data/portraits/, data/category-covers/ folders.\n\n" + "Existing folders will be renamed to *.pre-restore-/ for manual rollback. The database is snapshotted first.\n\n" + "This can take a long time for multi-GB archives. Continue?", )) { importingZipRef.current = false; return; } setImportingZip(true); setMessage(null); try { // Stream the file as the request body so multi-GB uploads don't have // to be buffered into memory before sending. The route streams it // straight to disk on the server side. const res = await fetch("/api/backup/library-import", { method: "POST", headers: { "Content-Type": "application/zip" }, body: file, // @ts-expect-error - Node fetch undici extension; lets the body stream. duplex: "half", }); const j = await res.json(); if (!res.ok) throw new Error(j.error ?? `Restore failed (${res.status})`); const totals = Object.entries(j.counts ?? {}) .filter(([, n]) => (n as number) > 0) .map(([t, n]) => `${t}: ${n}`) .join(", "); const mediaParts = (j.mediaRestored ?? []).join(", "); setMessage({ kind: "ok", text: `Restore complete. DB — ${totals || "(empty)"}. Media — ${mediaParts || "(none)"}. Old folders kept as *.pre-restore-* for rollback.`, }); router.refresh(); } catch (e) { setMessage({ kind: "err", text: (e as Error).message }); } finally { importingZipRef.current = false; setImportingZip(false); if (zipFileRef.current) zipFileRef.current.value = ""; } } return (
Backup & Restore
Export backup — metadata only (JSON). Fast, small, safe to email.
Export full library — zip of library/, data/thumbs/, data/portraits/, data/category-covers/ plus database.json. Skips library/.superseded/. Can be many GB; browser shows progress.
{ const f = e.target.files?.[0]; if (f) handleImport(f); }} /> { const f = e.target.files?.[0]; if (f) handleImportZip(f); }} />
{message && (
{message.kind === "err" && } {message.text}
)}
); }