232 lines
9.0 KiB
TypeScript
232 lines
9.0 KiB
TypeScript
"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<HTMLInputElement>(null);
|
|
const zipFileRef = useRef<HTMLInputElement>(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-<timestamp>/ 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 (
|
|
<div className="py-2">
|
|
<div className="text-sm font-medium mb-1">Backup & Restore</div>
|
|
<div className="text-xs text-[var(--color-fg-muted)] mb-3">
|
|
<strong>Export backup</strong> — metadata only (JSON). Fast, small, safe to email.
|
|
<br />
|
|
<strong>Export full library</strong> — zip of <span className="font-mono">library/</span>, <span className="font-mono">data/thumbs/</span>, <span className="font-mono">data/portraits/</span>, <span className="font-mono">data/category-covers/</span> plus <span className="font-mono">database.json</span>. Skips <span className="font-mono">library/.superseded/</span>. Can be many GB; browser shows progress.
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleExport}
|
|
disabled={exporting}
|
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
|
>
|
|
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
|
Export backup
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleExportAll}
|
|
disabled={exportingAll}
|
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
|
>
|
|
{exportingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Archive className="w-4 h-4" />}
|
|
Export full library
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={importing}
|
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
|
|
>
|
|
{importing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
Import backup…
|
|
</button>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
hidden
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImport(f); }}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => zipFileRef.current?.click()}
|
|
disabled={importingZip}
|
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
|
|
>
|
|
{importingZip ? <Loader2 className="w-4 h-4 animate-spin" /> : <PackageOpen className="w-4 h-4" />}
|
|
Restore full library…
|
|
</button>
|
|
<input
|
|
ref={zipFileRef}
|
|
type="file"
|
|
accept="application/zip,.zip"
|
|
hidden
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImportZip(f); }}
|
|
/>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
className={`mt-3 flex items-start gap-2 text-xs ${
|
|
message.kind === "ok" ? "text-[var(--color-mint)]" : "text-red-300"
|
|
}`}
|
|
>
|
|
{message.kind === "err" && <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />}
|
|
<span className="break-words">{message.text}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|