Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+231
View File
@@ -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 &amp; 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>
);
}