Initial commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user