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
@@ -0,0 +1,93 @@
"use client";
import { RotateCcw } from "lucide-react";
import { useSettings } from "./SettingsProvider";
const DEFAULT_PRIMARY_HEX = "#4dc4d4";
const DEFAULT_SECONDARY_HEX = "#b772f0";
export function AccentColorPickers() {
const { settings, set } = useSettings();
return (
<div className="space-y-3">
<ColorRow
label="Primary Accent"
description="Used for buttons, toggles, focus rings, and the cyan side of accent gradients."
value={settings.accentPrimary}
fallback={DEFAULT_PRIMARY_HEX}
onChange={(v) => set("accentPrimary", v)}
onReset={() => set("accentPrimary", "")}
/>
<ColorRow
label="Secondary Accent"
description="Used for the violet side of accent gradients, glows, and ambient page lighting."
value={settings.accentSecondary}
fallback={DEFAULT_SECONDARY_HEX}
onChange={(v) => set("accentSecondary", v)}
onReset={() => set("accentSecondary", "")}
/>
</div>
);
}
function ColorRow({
label,
description,
value,
fallback,
onChange,
onReset,
}: {
label: string;
description?: string;
value: string;
fallback: string;
onChange: (v: string) => void;
onReset: () => void;
}) {
const isDefault = value === "";
const display = value || fallback;
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-fg-muted)] mt-1">
{isDefault ? "default" : display}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<label
className="relative w-9 h-9 rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-glass)] cursor-pointer overflow-hidden grid place-items-center hover:border-[color-mix(in_oklch,var(--color-cyan)_50%,var(--color-glass-border))] transition-colors"
title={`Change ${label.toLowerCase()}`}
>
<span
className="absolute inset-1 rounded-md"
style={{ background: display }}
aria-hidden
/>
<input
type="color"
value={display}
onChange={(e) => onChange(e.target.value.toLowerCase())}
className="absolute inset-0 opacity-0 cursor-pointer"
aria-label={label}
/>
</label>
<button
type="button"
onClick={onReset}
disabled={isDefault}
aria-label={`Reset ${label.toLowerCase()}`}
title="Reset to default"
className="w-9 h-9 grid place-items-center rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:border-[color-mix(in_oklch,var(--color-cyan)_50%,var(--color-glass-border))] transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-[var(--color-glass-border-strong)]"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
);
}
+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>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw, CheckCircle2 } from "lucide-react";
import { clearCache } from "@/app/actions/maintenance";
export function ClearCacheButton() {
const [done, setDone] = useState(false);
const [pending, start] = useTransition();
const router = useRouter();
const onClick = () => {
start(async () => {
await clearCache();
router.refresh();
setDone(true);
setTimeout(() => setDone(false), 1600);
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Clear Cache</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Drop the in-memory settings cache and force every page to re-fetch from the database.
Useful if data looks stale after a manual edit.
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{done ? (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-mint)]">
<CheckCircle2 className="w-3.5 h-3.5" /> Cleared
</span>
) : (
<button
onClick={onClick}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] disabled:opacity-50 whitespace-nowrap"
>
<RefreshCw className={`w-3.5 h-3.5 ${pending ? "animate-spin" : ""}`} />
{pending ? "Clearing…" : "Clear"}
</button>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useCallback, useRef, useState, useTransition } from "react";
import { Check, ChevronDown } from "lucide-react";
import { setDefaultSort } from "@/app/actions/sort";
import { SORT_OPTIONS, labelFor, type SortKey } from "@/lib/sort";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
export function DefaultSortSelect({ initial }: { initial: SortKey }) {
const [value, setValue] = useState<SortKey>(initial);
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState(false);
const [pending, start] = useTransition();
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const choose = (next: SortKey) => {
setOpen(false);
if (next === value) return;
setValue(next);
start(async () => {
await setDefaultSort(next);
setSaved(true);
setTimeout(() => setSaved(false), 1400);
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="text-sm font-medium">Default Sort</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Used on every grid page when no sort is chosen. Persisted on the server.
</div>
</div>
{saved && (
<span className="flex items-center gap-1 text-xs text-[var(--color-mint)]">
<Check className="w-3 h-3" /> Saved
</span>
)}
</div>
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
disabled={pending}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-lg glass glass-hover text-[var(--color-fg)]"
>
<span>{labelFor(value)}</span>
<ChevronDown className={cn("w-3.5 h-3.5 opacity-60 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
className="absolute left-0 right-0 top-full mt-2 z-30 rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden p-1"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
{SORT_OPTIONS.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => choose(o.value)}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm text-left hover:bg-[var(--color-glass)]",
active && "text-[var(--color-cyan)]"
)}
>
<span>{o.label}</span>
{active && <Check className="w-3.5 h-3.5" />}
</button>
);
})}
</div>
)}
</div>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Copy, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import {
previewNearDupes,
backfillPhashes,
findNearDuplicates,
type NearDupePair,
type NearDupesPreview,
} from "@/app/actions/maintenance";
import { thumbUrl } from "@/lib/assetUrls";
import { useSettingsPanel } from "./SettingsPanelProvider";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; data: NearDupesPreview }
| { kind: "backfilling" }
| { kind: "running" }
| { kind: "result"; pairs: NearDupePair[] };
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function NearDupesButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const [threshold, setThreshold] = useState(10);
const { close: closeSettings } = useSettingsPanel();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const data = await previewNearDupes();
setState({ kind: "preview", data });
});
};
const backfillAndFind = async () => {
setState({ kind: "backfilling" });
await backfillPhashes();
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
const findOnly = async () => {
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Find Near-Duplicate Covers</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Compares perceptual hashes of every cover and surfaces pairs that look the same but
aren&apos;t SHA-identical different encodes, mild crops, or upscales of the same image.
Complements the SHA dedup and code-collision detectors.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-2">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.data.hashed}</span> hashed
{" · "}
<span className="font-mono text-[var(--color-amber,#fbbf24)]">{state.data.unhashed}</span> need backfill
{" · "}of {state.data.total} covers
</div>
<div className="flex items-center gap-2">
<label className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">
Threshold
</label>
<input
type="range"
min={0}
max={20}
step={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="flex-1 max-w-[200px] accent-[var(--color-cyan)]"
/>
<span className="font-mono text-[var(--color-cyan)] tabular-nums w-8">{threshold}</span>
</div>
<div className="text-[11px] text-[var(--color-fg-muted)]">
0 = identical · 5 = very tight · 10 = robust default · 20 = noisy
</div>
</div>
)}
{state.kind === "result" && (
<div className="text-xs mt-2 flex items-center gap-1.5">
{state.pairs.length === 0 ? (
<span className="text-[var(--color-mint)]">No near-duplicate pairs found at this threshold.</span>
) : (
<span className="text-[var(--color-coral)] flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
{state.pairs.length} near-duplicate pair{state.pairs.length === 1 ? "" : "s"} (distance {threshold}).
</span>
)}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Copy className="w-3.5 h-3.5" /> Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "backfilling" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Counting…" : state.kind === "backfilling" ? "Hashing…" : "Comparing…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{state.data.unhashed > 0 ? (
<button
onClick={() => start(() => backfillAndFind())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Hash {state.data.unhashed} & find pairs
</button>
) : (
<button
onClick={() => start(() => findOnly())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Find pairs
</button>
)}
</>
)}
{state.kind === "result" && (
<>
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Re-scan
</button>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
</>
)}
</div>
</div>
{state.kind === "result" && state.pairs.length > 0 && (
<div className="mt-3 max-h-96 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40 divide-y divide-[var(--color-glass-border)]">
{state.pairs.map((p) => {
const aBigger = p.a.width * p.a.height >= p.b.width * p.b.height;
const aMoreBytes = p.a.bytes >= p.b.bytes;
return (
<div key={`${p.a.id}-${p.b.id}`} className="p-2">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-[var(--color-coral)]/15 text-[var(--color-coral)]">
Δ {p.distance}
</span>
<span className="text-[11px] text-[var(--color-fg-muted)]">
Hamming distance lower = more similar
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<DupeCell side={p.a} bigger={aBigger} moreBytes={aMoreBytes} onNavigate={closeSettings} />
<DupeCell side={p.b} bigger={!aBigger} moreBytes={!aMoreBytes} onNavigate={closeSettings} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function DupeCell({
side,
bigger,
moreBytes,
onNavigate,
}: {
side: NearDupePair["a"];
bigger: boolean;
moreBytes: boolean;
onNavigate: () => void;
}) {
return (
<Link
href={`/image/${side.id}`}
onClick={onNavigate}
className="flex items-center gap-2 rounded-md p-2 hover:bg-[var(--color-glass)] transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: side.thumbPath, code: side.code, id: side.id })}
alt=""
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
{side.code ? (
<span className="font-mono font-bold text-[var(--color-cyan)]">{side.code}</span>
) : (
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
)}
<span className={`font-mono tabular-nums text-[11px] ${bigger ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{side.width}×{side.height}
</span>
<span className={`font-mono tabular-nums text-[11px] ${moreBytes ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{fmtBytes(side.bytes)}
</span>
</div>
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
{side.filename}
</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
</Link>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Plus, Trash2, ArrowUp, ArrowDown, Save, RotateCcw, Hash } from "lucide-react";
import { setPartSuffixPatterns } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { cn } from "@/lib/utils";
const DEFAULT_PATTERNS = ["-cd{N}", ".part{N}", "_{N}", "_{L}"];
/**
* Validate a token-grammar pattern. Returns null if valid, or a short
* error message if not. Mirrors the rules in lib/video/partClassify.ts.
*/
function validateToken(source: string): string | null {
if (!source.trim()) return "empty";
let i = 0;
let captures = 0;
while (i < source.length) {
const c = source[i]!;
if (c === "{") {
const close = source.indexOf("}", i);
if (close < 0) return "unclosed {";
const tok = source.slice(i, close + 1);
if (tok !== "{N}" && tok !== "{L}") return `unknown token ${tok}`;
captures++;
i = close + 1;
} else {
i++;
}
}
if (captures === 0) return "needs {N} or {L}";
if (captures > 1) return "only one {N}/{L} per pattern";
return null;
}
/**
* Settings card body for editing the suffix patterns used to classify
* video parts vs. variants. Uses option A1 (token grammar) from the
* mockup — each row is a single editable pattern with reorder/delete.
*/
export function PartSuffixPatterns() {
const { settings } = useSettings();
const router = useRouter();
const [draft, setDraft] = useState<string[]>(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
// Keep the editor in sync with server-side updates (e.g. another tab).
useEffect(() => {
setDraft(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
}, [settings.partSuffixPatterns]);
const errors = draft.map(validateToken);
const hasErrors = errors.some((e) => e != null);
const dirty =
draft.length !== (settings.partSuffixPatterns ?? []).length ||
draft.some((v, i) => v !== (settings.partSuffixPatterns ?? [])[i]);
function update(i: number, value: string) {
setDraft((cur) => cur.map((v, idx) => (idx === i ? value : v)));
}
function remove(i: number) {
setDraft((cur) => cur.filter((_, idx) => idx !== i));
}
function add() {
setDraft((cur) => [...cur, ""]);
}
function move(i: number, dir: -1 | 1) {
setDraft((cur) => {
const j = i + dir;
if (j < 0 || j >= cur.length) return cur;
const next = cur.slice();
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
}
function resetDefaults() {
setDraft(DEFAULT_PATTERNS.slice());
}
async function save() {
if (hasErrors) return;
setSaving(true);
try {
// Strip empties on save (the action also trims, but UX clarity).
const cleaned = draft.map((s) => s.trim()).filter(Boolean);
await setPartSuffixPatterns(cleaned);
startTransition(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Hash className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Part Suffix Patterns
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Match the trailing portion of a filename stem to identify
sequential parts.{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{N}"}</span>{" "}
captures digits,{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{L}"}</span>{" "}
captures a single letter (A=1, B=2). All other characters are
literal. Files in a code group that match nothing become
<em> variants</em> of the part they share a stem prefix with.
</div>
</div>
<ol className="space-y-1.5">
{draft.map((p, i) => {
const err = errors[i];
return (
<li key={i} className="flex items-center gap-2">
<input
type="text"
value={p}
onChange={(e) => update(i, e.target.value)}
placeholder="-cd{N}"
className={cn(
"flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none",
err
? "border border-red-500/50 focus:border-red-400"
: "focus:border-[var(--color-cyan)]",
)}
/>
<button
type="button"
onClick={() => move(i, -1)}
disabled={i === 0}
title="Move up"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => move(i, 1)}
disabled={i === draft.length - 1}
title="Move down"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => remove(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
);
})}
</ol>
{hasErrors && (
<div className="text-[11px] text-red-300">
{errors.map((e, i) => (e ? `Line ${i + 1}: ${e}` : null)).filter(Boolean).join(" · ")}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Plus className="w-4 h-4" /> Add Pattern
</button>
<button
type="button"
onClick={resetDefaults}
title="Reset to built-in defaults"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-muted)]"
>
<RotateCcw className="w-4 h-4" /> Reset to Defaults
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving || hasErrors}
className="ml-auto inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Reclassify
</button>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useTransition } from "react";
import { Trash2, Loader2, CheckCircle2 } from "lucide-react";
import { previewOrphanFiles, purgeOrphanFiles } from "@/app/actions/maintenance";
import { formatBytes } from "@/lib/utils";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "result"; count: number; bytes: number }
| { kind: "deleted"; deleted: number; bytes: number };
export function PurgeOrphansButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewOrphanFiles();
setState({ kind: "result", count: r.count, bytes: r.bytes });
});
};
const purge = () => {
if (state.kind !== "result") return;
if (!confirm(`Delete ${state.count} orphan file${state.count === 1 ? "" : "s"} (${formatBytes(state.bytes)}) from disk? Cannot be undone.`)) return;
start(async () => {
const r = await purgeOrphanFiles();
setState({ kind: "deleted", deleted: r.deleted, bytes: r.bytes });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Purge Orphan Files</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Find and delete files in the library / thumbnail folders that no image record references.
Useful after deleting images with Delete files from disk turned off.
</div>
{state.kind === "result" && (
<div className="text-xs mt-2">
<span className="font-mono text-[var(--color-cyan)]">{state.count}</span>
<span className="text-[var(--color-fg-dim)]"> orphan{state.count === 1 ? "" : "s"} · {formatBytes(state.bytes)}</span>
</div>
)}
{state.kind === "deleted" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2">
<CheckCircle2 className="w-3.5 h-3.5" />
Deleted {state.deleted} file{state.deleted === 1 ? "" : "s"} · freed {formatBytes(state.bytes)}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{state.kind === "scanning" && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Scanning
</span>
)}
{state.kind === "result" && state.count > 0 && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
<button
onClick={purge}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-40 whitespace-nowrap"
>
<Trash2 className="w-3.5 h-3.5" /> {pending ? "Deleting…" : "Delete"}
</button>
</>
)}
{state.kind === "result" && state.count === 0 && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
)}
{state.kind === "deleted" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState, useTransition } from "react";
import { ImageIcon, Loader2, CheckCircle2 } from "lucide-react";
import { previewRegenThumbnails, regenerateThumbnails } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; total: number; missing: number; staleNames: number }
| { kind: "running" }
| { kind: "done"; regenerated: number; renamed: number; skipped: number; errors: number };
export function RegenThumbnailsButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewRegenThumbnails();
setState({ kind: "preview", total: r.total, missing: r.missing, staleNames: r.staleNames });
});
};
const run = (force: boolean) => {
if (state.kind !== "preview") return;
const count = force ? state.total : state.missing + state.staleNames;
const verb = force ? "Re-encode" : "Regenerate missing + rename stale";
if (!confirm(`${verb} for ${count} thumbnail${count === 1 ? "" : "s"}? Reads each cover from library/ when it needs encoding and renames legacy files in place when possible. Cannot be undone.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await regenerateThumbnails({ force });
setState({ kind: "done", ...r });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Regenerate Thumbnails</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Rebuild the grid-preview WebP files in <code className="font-mono">data/thumbs/</code> from the
originals in <code className="font-mono">library/</code>, and rename legacy <code className="font-mono">&lt;sha&gt;.webp</code>
files to the new <code className="font-mono">&lt;CODE&gt;-&lt;sha&gt;.webp</code> format. Use this if your thumbs folder
was wiped, restored from an incomplete backup, or you upgraded to the code-prefix naming.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-0.5">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.missing}</span> missing on disk
{" · "}
<span className="font-mono text-[var(--color-cyan)]">{state.staleNames}</span> with legacy filename
{" · "}of {state.total} total
</div>
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2 flex-wrap">
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Encoded {state.regenerated} · renamed {state.renamed} · skipped {state.skipped}</span>
{state.errors > 0 && <span className="text-[var(--color-coral)]">· {state.errors} error{state.errors === 1 ? "" : "s"}</span>}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Regenerating…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{(state.missing > 0 || state.staleNames > 0) && (
<button
onClick={() => run(false)}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
<ImageIcon className="w-3.5 h-3.5" /> Fix {state.missing + state.staleNames}
</button>
)}
<button
onClick={() => run(true)}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-glass-border-strong)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] disabled:opacity-40 whitespace-nowrap"
title="Re-encode all thumbnails, replacing existing files. Useful after changing sharp/quality settings."
>
Re-encode all
</button>
</>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState, useTransition } from "react";
import { FolderTree, Loader2, CheckCircle2 } from "lucide-react";
import { previewReorganize, reorganizeFiles } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; total: number; toMove: number }
| { kind: "running" }
| { kind: "done"; moved: number; skipped: number; errors: number };
export function ReorganizeButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewReorganize();
setState({ kind: "preview", total: r.total, toMove: r.toMove });
});
};
const run = () => {
if (state.kind !== "preview") return;
if (!confirm(`Move ${state.toMove} file${state.toMove === 1 ? "" : "s"} into letter buckets on disk? This relocates files; cannot be undone.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await reorganizeFiles();
setState({ kind: "done", ...r });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Re-organize Files</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Move covers into letter buckets on disk {" "}
<code className="font-mono">A-E / F-J / K-P / Q-U / V-Z</code> at the top level, single-letter
folders inside, keyed off each cover&apos;s code. Files without a code go to{" "}
<code className="font-mono">#/#/</code>. Attached images bucket with their parent.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2">
<span className="font-mono text-[var(--color-cyan)]">{state.toMove}</span>
<span className="text-[var(--color-fg-dim)]"> of {state.total} need to move</span>
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2">
<CheckCircle2 className="w-3.5 h-3.5" />
Moved {state.moved} · skipped {state.skipped}
{state.errors > 0 && <span className="text-[var(--color-coral)]">· {state.errors} error{state.errors === 1 ? "" : "s"}</span>}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Moving…"}
</span>
)}
{state.kind === "preview" && state.toMove > 0 && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
<button
onClick={run}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
<FolderTree className="w-3.5 h-3.5" /> Re-organize
</button>
</>
)}
{state.kind === "preview" && state.toMove === 0 && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useState, useTransition } from "react";
import { Hash, Loader2, CheckCircle2 } from "lucide-react";
import { previewReparseCodes, reparseCodes, type ReparseCodesPreview } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; data: ReparseCodesPreview }
| { kind: "running" }
| { kind: "done"; filled: number; updated: number; skipped: number };
export function ReparseCodesButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const data = await previewReparseCodes();
setState({ kind: "preview", data });
});
};
const run = (force: boolean) => {
if (state.kind !== "preview") return;
const count = force ? state.data.missing + state.data.changed : state.data.missing;
const verb = force ? "Re-parse all (overwrite manual edits)" : "Fill missing only";
if (!confirm(`${verb} for ${count} cover${count === 1 ? "" : "s"}? Files won't move into new letter buckets until you also run Re-organize, and thumbnail filenames won't update until Regenerate Thumbnails runs.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await reparseCodes({ force });
setState({ kind: "done", ...r });
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Re-parse Codes From Filenames</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Re-run the JAV-code parser against every cover&apos;s stored filename. Useful after a parser
change (added <code className="font-mono">z</code>-suffix or alphanumeric prefix support, etc.) so old
rows pick up the new behaviour. Pair with <em>Re-organize</em> + <em>Regenerate Thumbnails</em>
to also move files and rename thumbs.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-0.5">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.data.missing}</span> missing
{" · "}
<span className="font-mono text-[var(--color-amber,#fbbf24)]">{state.data.changed}</span> would change
{" · "}of {state.data.total} top-level covers
</div>
{state.data.sampleChanges.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]">
Preview {state.data.sampleChanges.length} sample change{state.data.sampleChanges.length === 1 ? "" : "s"}
</summary>
<div className="mt-1.5 max-h-40 overflow-y-auto rounded-md border border-[var(--color-glass-border)] p-2 space-y-0.5 font-mono text-[11px]">
{state.data.sampleChanges.map((c) => (
<div key={c.id} className="flex items-baseline gap-2">
<span className="text-[var(--color-coral)] line-through">{c.oldCode}</span>
<span className="text-[var(--color-fg-muted)]"></span>
<span className="text-[var(--color-mint)]">{c.newCode}</span>
<span className="text-[var(--color-fg-muted)] truncate">{c.filename}</span>
</div>
))}
</div>
</details>
)}
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2 flex-wrap">
<CheckCircle2 className="w-3.5 h-3.5" />
Filled {state.filled} · updated {state.updated} · skipped {state.skipped}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Hash className="w-3.5 h-3.5" /> Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Updating…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{state.data.missing > 0 && (
<button
onClick={() => run(false)}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Fill {state.data.missing} missing
</button>
)}
{state.data.changed > 0 && (
<button
onClick={() => run(true)}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-40 whitespace-nowrap"
title="Overwrite codes that disagree with the parser. Will clobber any code you set manually."
>
Force overwrite {state.data.changed}
</button>
)}
</>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Settings as SettingsIcon, X, Palette, Trash2, Wrench, Film, FolderTree, Captions,
} from "lucide-react";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { DefaultSortSelect } from "./DefaultSortSelect";
import { AccentColorPickers } from "./AccentColorPickers";
import { DisplayGroup, TrashGroup, MaintenanceGroup, BackupGroup } from "./SettingsToggles";
import { VideoLibrarySettings } from "./VideoLibrarySettings";
import { WhisperJavSettings } from "./WhisperJavSettings";
import { SubtitleLibraryPaths } from "./SubtitleLibraryPaths";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
import type { SortKey } from "@/lib/sort";
import type { LibraryStats } from "@/lib/db/queries";
interface PanelData {
defaultSort: SortKey;
stats: LibraryStats;
libraryRoot: string;
dbPath: string;
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function fmtDate(ms: number | null): string {
if (!ms) return "—";
const d = new Date(ms);
return d.toISOString().slice(0, 10);
}
export function SettingsPanel({ data }: { data: PanelData }) {
const { open, close } = useSettingsPanel();
const panelRef = useRef<HTMLDivElement>(null);
useClickOutside(panelRef, close, open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={panelRef}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)] shrink-0">
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-[var(--color-cyan)]" />
<h2 className="text-xl font-semibold tracking-tight">Settings</h2>
</div>
<button
onClick={close}
aria-label="Close settings"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</header>
<SidebarLayout data={data} />
</div>
</div>
);
}
/* =====================================================================
Section content blocks. Five top-level sections:
Appearance · Library · Video · Tools · Info
===================================================================== */
function AppearanceSection({ data }: { data: PanelData }) {
return (
<Card title="Appearance">
<SubGroup label="Colors">
<AccentColorPickers />
</SubGroup>
<Divider />
<SubGroup label="Display">
<DisplayGroup />
</SubGroup>
<Divider />
<SubGroup label="Defaults">
<DefaultSortSelect initial={data.defaultSort} />
</SubGroup>
</Card>
);
}
function LibrarySection() {
return (
<Card title="Library · file handling">
<TrashGroup />
</Card>
);
}
function VideoSection() {
return <Card title="Video"><VideoLibrarySettings /></Card>;
}
function SubtitlesSection() {
return (
<Card title="Subtitles">
<SubtitleLibraryPaths />
<Divider />
<WhisperJavSettings />
</Card>
);
}
function ToolsSection() {
return (
<Card title="Tools">
<SubGroup label="Maintenance">
<MaintenanceGroup />
</SubGroup>
<Divider />
<SubGroup label="Backup">
<BackupGroup />
</SubGroup>
</Card>
);
}
function InfoSection({ data }: { data: PanelData }) {
const s = data.stats;
const watchedPct = s.images > 0 ? Math.round((s.watched / s.images) * 100) : 0;
return (
<Card title="Info">
<div className="grid grid-cols-1 md:grid-cols-2 gap-section">
<StatGroup label="Covers">
<Row label="Top-level" value={s.images.toLocaleString()} mono />
<Row label="Attached (back / stills)" value={s.attached.toLocaleString()} mono />
{s.trashed > 0 && (
<Row label="In trash" value={s.trashed.toLocaleString()} mono />
)}
</StatGroup>
<StatGroup label="Entities">
<Row label="Actresses" value={s.actresses.toLocaleString()} mono />
<Row label="Studios" value={s.studios.toLocaleString()} mono />
<Row label="Series" value={s.series.toLocaleString()} mono />
{s.labels > 0 && <Row label="Labels" value={s.labels.toLocaleString()} mono />}
<Row label="Genres" value={s.genres.toLocaleString()} mono />
</StatGroup>
<StatGroup label="Tagging">
<Row label="Tags" value={s.tags.toLocaleString()} mono />
<Row label="Tag categories" value={s.tagCategories.toLocaleString()} mono />
<Row label="Collections" value={s.collections.toLocaleString()} mono />
</StatGroup>
<StatGroup label="State">
<Row label="Watched" value={`${s.watched.toLocaleString()} (${watchedPct}%)`} mono />
<Row label="VIP" value={s.vip.toLocaleString()} mono />
<Row label="Favorite" value={s.favorite.toLocaleString()} mono />
<Row label="Owned" value={s.owned.toLocaleString()} mono />
<Row label="Rated" value={s.rated.toLocaleString()} mono />
</StatGroup>
</div>
<Divider />
<SubGroup label="Disk">
<dl className="space-y-1.5 text-sm">
<Row label="Total cover bytes" value={fmtBytes(s.totalBytes)} mono />
<Row
label="Imports"
value={
s.earliestImportedAt && s.latestImportedAt
? `${fmtDate(s.earliestImportedAt)}${fmtDate(s.latestImportedAt)}`
: "—"
}
mono
/>
</dl>
</SubGroup>
<Divider />
<SubGroup label="Paths">
<dl className="space-y-1.5 text-sm">
<Row label="Library folder" value={data.libraryRoot} mono />
<Row label="Database" value={data.dbPath} mono />
</dl>
</SubGroup>
</Card>
);
}
/* =====================================================================
Sidebar layout — single layout, no toggle.
===================================================================== */
const SIDEBAR_NAV = [
{ id: "appearance", label: "Appearance", Icon: Palette },
{ id: "library", label: "Library", Icon: Trash2 },
{ id: "video", label: "Video", Icon: Film },
{ id: "subtitles", label: "Subtitles", Icon: Captions },
{ id: "tools", label: "Tools", Icon: Wrench },
{ id: "info", label: "Info", Icon: FolderTree },
] as const;
type SidebarSection = typeof SIDEBAR_NAV[number]["id"];
function SidebarLayout({ data }: { data: PanelData }) {
const [active, setActive] = useState<SidebarSection>("appearance");
const content: Record<SidebarSection, React.ReactNode> = {
appearance: <AppearanceSection data={data} />,
library: <LibrarySection />,
video: <VideoSection />,
subtitles: <SubtitlesSection />,
tools: <ToolsSection />,
info: <InfoSection data={data} />,
};
return (
<div className="flex-1 grid grid-cols-[220px_1fr] min-h-0">
<nav className="border-r border-[var(--color-glass-border)] p-3 overflow-y-auto"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 50%, transparent)" }}>
{SIDEBAR_NAV.map(({ id, label, Icon }) => {
const isActive = id === active;
return (
<button
key={id}
type="button"
onClick={() => setActive(id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors mb-0.5",
isActive
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
<div className="overflow-y-auto p-card">
{content[active]}
</div>
</div>
);
}
/* =====================================================================
Tiny primitives.
===================================================================== */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="glass rounded-2xl p-card">
<h3 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label">
{title}
</h3>
{children}
</section>
);
}
/** Sub-group label inside a Card — small cyan caps header. */
function SubGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
{children}
</div>
);
}
/** Horizontal rule between sub-groups inside a Card. */
function Divider() {
return <hr className="my-section border-0 border-t border-[var(--color-glass-border)]" />;
}
function StatGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
<dl className="space-y-1.5 text-sm border-t border-[var(--color-glass-border)] pt-1.5">
{children}
</dl>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-4">
<dt className="text-[var(--color-fg-dim)]">{label}</dt>
<dd className={`text-right break-all ${mono ? "font-mono text-xs text-[var(--color-fg)]" : ""}`}>{value}</dd>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = { open: boolean; toggle: () => void; close: () => void };
const C = createContext<Ctx | null>(null);
export function SettingsPanelProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo<Ctx>(() => ({ open, toggle, close }), [open, toggle, close]);
return <C.Provider value={value}>{children}</C.Provider>;
}
export function useSettingsPanel() {
const ctx = useContext(C);
if (!ctx) throw new Error("useSettingsPanel must be used within SettingsPanelProvider");
return ctx;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { AppSettings } from "@/lib/db/appSettings";
import { setBoolSetting, setNumberSetting, setColorSetting, setPaginationMode } from "@/app/actions/settings";
type BoolKey = "fadeTransitions" | "purgeFilesOnDelete" | "useRecycleBin";
type NumKey = "fadeDurationMs" | "trashRetentionDays" | "gridColumns" | "gridColumnsPortrait" | "supersededRetentionDays" | "coverPageSize";
type ColorKey = "accentPrimary" | "accentSecondary";
type PaginationModeKey = "paginationMode";
interface Ctx {
settings: AppSettings;
set(key: BoolKey, value: boolean): void;
set(key: NumKey, value: number): void;
set(key: ColorKey, value: string): void;
set(key: PaginationModeKey, value: AppSettings["paginationMode"]): void;
}
const SettingsCtx = createContext<Ctx | null>(null);
const NUM_KEYS = new Set<string>(["fadeDurationMs", "trashRetentionDays", "gridColumns", "gridColumnsPortrait", "supersededRetentionDays", "coverPageSize"]);
const COLOR_KEYS = new Set<string>(["accentPrimary", "accentSecondary"]);
const PAGINATION_MODE_KEYS = new Set<string>(["paginationMode"]);
const ACCENT_VARS: Record<ColorKey, [string, string]> = {
accentPrimary: ["--color-cyan", "--color-cyan-glow"],
accentSecondary: ["--color-violet", "--color-violet-glow"],
};
export function SettingsProvider({
initial,
children,
}: {
initial: AppSettings;
children: React.ReactNode;
}) {
const [settings, setSettings] = useState<AppSettings>(initial);
// The server is the source of truth, but the parent layout passes the latest
// server values on every render. Keep our state in sync after server mutations.
useEffect(() => {
setSettings(initial);
}, [initial]);
useEffect(() => {
document.documentElement.dataset.fade = settings.fadeTransitions ? "on" : "off";
document.documentElement.style.setProperty("--fade-duration", `${settings.fadeDurationMs}ms`);
}, [settings.fadeTransitions, settings.fadeDurationMs]);
useEffect(() => {
const n = Math.max(2, Math.min(4, settings.gridColumns || 3));
document.documentElement.style.setProperty("--grid-cols", String(n));
}, [settings.gridColumns]);
useEffect(() => {
const n = Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6));
document.documentElement.style.setProperty("--grid-cols-portrait", String(n));
}, [settings.gridColumnsPortrait]);
useEffect(() => {
const root = document.documentElement;
for (const [key, [base, glow]] of Object.entries(ACCENT_VARS) as [ColorKey, [string, string]][]) {
const value = settings[key];
if (value) {
root.style.setProperty(base, value);
root.style.setProperty(glow, value);
} else {
root.style.removeProperty(base);
root.style.removeProperty(glow);
}
}
}, [settings.accentPrimary, settings.accentSecondary]);
const set = useCallback((key: BoolKey | NumKey | ColorKey | PaginationModeKey, value: boolean | number | string) => {
setSettings((cur) => ({ ...cur, [key]: value }));
if (NUM_KEYS.has(key)) {
void setNumberSetting(key as NumKey, value as number);
} else if (COLOR_KEYS.has(key)) {
void setColorSetting(key as ColorKey, value as string);
} else if (PAGINATION_MODE_KEYS.has(key)) {
void setPaginationMode(value as AppSettings["paginationMode"]);
} else {
void setBoolSetting(key as BoolKey, value as boolean);
}
}, []) as Ctx["set"];
const value = useMemo<Ctx>(() => ({ settings, set }), [settings, set]);
return <SettingsCtx.Provider value={value}>{children}</SettingsCtx.Provider>;
}
export function useSettings() {
const ctx = useContext(SettingsCtx);
if (!ctx) throw new Error("useSettings must be used within SettingsProvider");
return ctx;
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useSettings } from "./SettingsProvider";
import { PurgeOrphansButton } from "./PurgeOrphansButton";
import { ReorganizeButton } from "./ReorganizeButton";
import { RegenThumbnailsButton } from "./RegenThumbnailsButton";
import { UndersizedCoversButton } from "./UndersizedCoversButton";
import { NearDupesButton } from "./NearDupesButton";
import { ReparseCodesButton } from "./ReparseCodesButton";
import { ClearCacheButton } from "./ClearCacheButton";
import { BackupButtons } from "./BackupButtons";
import { cn } from "@/lib/utils";
/** Display group: grid columns + fade transitions. */
export function DisplayGroup() {
const { settings, set } = useSettings();
return (
<div className="space-y-1">
<SliderRow
label="Grid Columns (Landscape)"
description="Number of cover columns shown when the library is in L (landscape / full cover) view."
min={2}
max={4}
step={1}
value={settings.gridColumns}
onChange={(v) => set("gridColumns", v)}
format={(v) => `${v} per row`}
/>
<SliderRow
label="Grid Columns (Portrait)"
description="Number of cover columns shown when the library is in P (portrait / front-only) view."
min={4}
max={10}
step={1}
value={settings.gridColumnsPortrait}
onChange={(v) => set("gridColumnsPortrait", v)}
format={(v) => `${v} per row`}
/>
<SliderRow
label="Items Per Page"
description="Cover grid page size. Pagination + infinite scroll fetch this many at a time."
min={25}
max={500}
step={25}
value={settings.coverPageSize}
onChange={(v) => set("coverPageSize", v)}
format={(v) => `${v} per page`}
/>
<SegmentedRow
label="Pagination Behavior"
description={
settings.paginationMode === "url"
? "Prev / Next / Jump always pushes a new URL and remounts the grid. Predictable; small flash on each click."
: "Prev / Next scrolls within the loaded buffer. Forward jumps prefetch missing pages on the fly. Backward across the SSR anchor falls back to URL nav."
}
value={settings.paginationMode}
options={[
{ value: "url", label: "URL" },
{ value: "scroll", label: "Scroll" },
]}
onChange={(v) => set("paginationMode", v as "url" | "scroll")}
/>
<ToggleRow
label="Fade Transitions"
description="Fade in pages and image details when navigating."
value={settings.fadeTransitions}
onChange={(v) => set("fadeTransitions", v)}
/>
{settings.fadeTransitions && (
<SliderRow
label="Fade Duration"
description="How long the fade-in animation takes."
min={100}
max={2000}
step={50}
value={settings.fadeDurationMs}
onChange={(v) => set("fadeDurationMs", v)}
format={(v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}s` : `${v}ms`)}
/>
)}
</div>
);
}
/** Trash & deletion group. */
export function TrashGroup() {
const { settings, set } = useSettings();
return (
<div className="space-y-1">
<ToggleRow
label="Use Recycle Bin"
description="Send deletes to the recycle bin instead of removing immediately. Off makes every delete permanent."
value={settings.useRecycleBin}
onChange={(v) => set("useRecycleBin", v)}
/>
{settings.useRecycleBin && (
<SliderRow
label="Trash Retention"
description="Automatically purge trashed images older than this. 0 keeps them forever."
min={0}
max={365}
step={1}
value={settings.trashRetentionDays}
onChange={(v) => set("trashRetentionDays", v)}
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
/>
)}
<ToggleRow
label="Delete Files From Disk When Emptying Trash"
description="When permanently removing an image (or emptying the bin), also delete the file and thumbnail from disk. Off keeps files on disk."
value={settings.purgeFilesOnDelete}
onChange={(v) => set("purgeFilesOnDelete", v)}
/>
<SliderRow
label="Superseded Retention"
description={`When you replace a cover via the collision dialog, the old file is moved to library/.superseded/ as a recovery snapshot. Files older than this are auto-purged on each app start. 0 = keep forever.`}
min={0}
max={365}
step={1}
value={settings.supersededRetentionDays}
onChange={(v) => set("supersededRetentionDays", v)}
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
/>
</div>
);
}
/** Maintenance buttons group. */
export function MaintenanceGroup() {
return (
<div className="divide-y divide-[var(--color-glass-border)]">
<PurgeOrphansButton />
<ReparseCodesButton />
<ReorganizeButton />
<RegenThumbnailsButton />
<UndersizedCoversButton />
<NearDupesButton />
<ClearCacheButton />
</div>
);
}
/** Backup group. */
export function BackupGroup() {
return (
<div className="space-y-1">
<BackupButtons />
</div>
);
}
/** Legacy combined view — preserved for any caller still rendering all
* groups inline. The new SettingsPanel layouts use the individual
* exports above. */
export function SettingsToggles() {
return (
<div className="space-y-5">
<Group title="Display"><DisplayGroup /></Group>
<Group title="Deletion & trash"><TrashGroup /></Group>
<Group title="Maintenance"><MaintenanceGroup /></Group>
<Group title="Backup"><BackupGroup /></Group>
</div>
);
}
function Group({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-1.5">{title}</div>
<div className="border-t border-[var(--color-glass-border)] pt-1">{children}</div>
</div>
);
}
function SliderRow({
label, description, min, max, step, value, onChange, format,
}: {
label: string;
description?: string;
min: number;
max: number;
step: number;
value: number;
onChange: (v: number) => void;
format?: (v: number) => string;
}) {
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<span className="text-xs font-mono text-[var(--color-cyan)] tabular-nums whitespace-nowrap">
{format ? format(value) : value}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--color-cyan)]"
/>
</div>
);
}
function SegmentedRow<T extends string>({
label,
description,
value,
options,
onChange,
}: {
label: string;
description?: string;
value: T;
options: ReadonlyArray<{ value: T; label: string }>;
onChange: (v: T) => void;
}) {
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<div className="flex flex-shrink-0 rounded-lg border border-[var(--color-glass-border-strong)] overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"min-w-[64px] px-3 py-1 text-xs font-mono transition-colors text-center",
value === opt.value
? "bg-[var(--color-cyan)]/20 text-[var(--color-cyan)]"
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<button
role="switch"
aria-checked={value}
onClick={() => onChange(!value)}
className={cn(
"relative w-11 h-6 rounded-full border transition-colors flex-shrink-0",
value
? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
: "bg-[var(--color-glass)] border-[var(--color-glass-border-strong)]"
)}
>
<span
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full transition-all",
value
? "left-[22px] bg-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]"
: "left-0.5 bg-[var(--color-fg-dim)]"
)}
/>
</button>
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Captions, Folder, FolderOpen, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { setSubtitleExtraPaths, setSubtitleCacheLimitMb } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
/**
* Persistent recursive-scan folders for subtitle sidecars. The player
* always finds same-folder sidecars; configure these only if subtitles
* live in a separate location from videos.
*/
export function SubtitleLibraryPaths() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draftSubExtras, setDraftSubExtras] = useState<string[]>(settings.subtitleExtraPaths ?? []);
const [newSubExtra, setNewSubExtra] = useState("");
const [saving, setSaving] = useState(false);
const [picking, setPicking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDraftSubExtras(settings.subtitleExtraPaths ?? []);
}, [settings.subtitleExtraPaths]);
async function pickFolder(startPath: string): Promise<string | null> {
try {
const r = await fetch("/api/pick-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start: startPath }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
return typeof j.path === "string" && j.path ? j.path : null;
} catch (e) {
setError((e as Error).message);
return null;
}
}
const dirty =
draftSubExtras.length !== (settings.subtitleExtraPaths ?? []).length ||
draftSubExtras.some((v, i) => v !== (settings.subtitleExtraPaths ?? [])[i]);
function addSubExtra() {
const v = newSubExtra.trim();
if (!v || draftSubExtras.includes(v)) return;
setDraftSubExtras((cur) => [...cur, v]);
setNewSubExtra("");
}
function removeSubExtra(idx: number) {
setDraftSubExtras((cur) => cur.filter((_, i) => i !== idx));
}
function updateSubExtra(idx: number, value: string) {
setDraftSubExtras((cur) => cur.map((v, i) => (i === idx ? value : v)));
}
async function browseExtra(idx: number) {
setPicking(`extra-${idx}`);
try {
const p = await pickFolder(draftSubExtras[idx] ?? "");
if (p) updateSubExtra(idx, p);
} finally {
setPicking(null);
}
}
async function browseNewExtra() {
setPicking("new");
try {
const p = await pickFolder(newSubExtra);
if (p) {
if (!draftSubExtras.includes(p)) {
setDraftSubExtras((cur) => [...cur, p]);
setNewSubExtra("");
} else {
setNewSubExtra(p);
}
}
} finally {
setPicking(null);
}
}
async function save() {
setSaving(true);
setError(null);
try {
await setSubtitleExtraPaths(draftSubExtras);
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Subtitle Library Folders
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursively scanned for subtitle sidecars matching the playing video&apos;s stem or code (depth 3).
The player always finds same-folder sidecars; configure these only if you keep subtitles in a separate location.
</div>
<div className="space-y-1.5">
{draftSubExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No subtitle folders.
</div>
) : (
draftSubExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => updateSubExtra(i, e.target.value)}
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeSubExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newSubExtra}
onChange={(e) => setNewSubExtra(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSubExtra(); } }}
placeholder="D:\\Subtitles"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addSubExtra}
disabled={!newSubExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
{error && (
<div className="mt-2 text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
<SubtitleCacheLimit />
</div>
);
}
function SubtitleCacheLimit() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draft, setDraft] = useState<number>(settings.subtitleCacheLimitMb ?? 100);
const [saving, setSaving] = useState(false);
useEffect(() => { setDraft(settings.subtitleCacheLimitMb ?? 100); }, [settings.subtitleCacheLimitMb]);
const dirty = draft !== (settings.subtitleCacheLimitMb ?? 100);
async function save() {
setSaving(true);
try {
await setSubtitleCacheLimitMb(Math.max(0, Math.floor(draft)));
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="mt-section pt-section border-t border-[var(--color-glass-border)]">
<div className="text-sm font-medium mb-1">Subtitle Cache Size Limit</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Soft cap on <span className="font-mono">data/subtitle-cache/</span> (converted WebVTT files).
When exceeded, oldest entries get evicted until size drops below 80% of the cap.{" "}
<span className="font-mono">0</span> = unlimited.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft}
onChange={(e) => setDraft(Math.max(0, Number(e.target.value) || 0))}
className="w-28 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">MB</span>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Ruler, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { scanUndersizedCovers, type UndersizedCover } from "@/app/actions/maintenance";
import { thumbUrl } from "@/lib/assetUrls";
import { useSettingsPanel } from "./SettingsPanelProvider";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "result"; rows: UndersizedCover[] };
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function UndersizedCoversButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const { close: closeSettings } = useSettingsPanel();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const rows = await scanUndersizedCovers();
setState({ kind: "result", rows });
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Find Undersized Covers</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Scan top-level covers smaller than standard JAV size (default
floor is <code className="font-mono">750×500</code>; real covers are
usually <code className="font-mono">800×538</code>). Catches
thumbnails or web previews accidentally imported as covers.
</div>
{state.kind === "result" && state.rows.length === 0 && (
<div className="text-xs text-[var(--color-mint)] mt-2">
No undersized covers all top-level covers meet the size threshold.
</div>
)}
{state.kind === "result" && state.rows.length > 0 && (
<div className="text-xs text-[var(--color-coral)] mt-2 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
{state.rows.length} undersized cover{state.rows.length === 1 ? "" : "s"} found.
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Ruler className="w-3.5 h-3.5" /> Scan
</button>
)}
{state.kind === "scanning" && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Scanning
</span>
)}
{state.kind === "result" && (
<>
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Re-scan
</button>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
</>
)}
</div>
</div>
{state.kind === "result" && state.rows.length > 0 && (
<div className="mt-3 max-h-72 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40">
{state.rows.map((r) => (
<Link
key={r.id}
href={`/image/${r.id}`}
onClick={closeSettings}
className="flex items-center gap-3 p-2 border-b border-[var(--color-glass-border)] last:border-b-0 hover:bg-[var(--color-glass)] transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: r.thumbPath, code: r.code, id: r.id })}
alt=""
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
{r.code ? (
<span className="font-mono font-bold text-[var(--color-cyan)]">{r.code}</span>
) : (
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
)}
<span className="font-mono text-[var(--color-coral)] tabular-nums">
{r.width}×{r.height}
</span>
<span className="font-mono text-[var(--color-fg-muted)] tabular-nums">
{fmtBytes(r.bytes)}
</span>
</div>
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
{r.filename}
</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,384 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Folder, Loader2, RefreshCw, Save, Plus, Trash2, FolderOpen, Cpu } from "lucide-react";
import {
setVideoLibraryPath,
setVideoExtraPaths,
setTranscodeMode,
} from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { PartSuffixPatterns } from "./PartSuffixPatterns";
import { dispatchVideoStatusRefresh } from "@/components/video/videoStatusEvents";
import { cn } from "@/lib/utils";
interface ScanResult {
count: number;
codes: number;
rootsScanned: string[];
elapsedMs: number;
}
export function VideoLibrarySettings() {
const { settings } = useSettings();
const [, startTranscode] = useTransition();
const [transcodeMode, setLocalTranscodeMode] = useState(settings.transcodeMode);
// Stay in sync if the server-side value changes (e.g. user edits in
// another tab and we router.refresh).
useEffect(() => { setLocalTranscodeMode(settings.transcodeMode); }, [settings.transcodeMode]);
const router = useRouter();
const [draftMain, setDraftMain] = useState(settings.videoLibraryPath);
const [draftExtras, setDraftExtras] = useState<string[]>(settings.videoExtraPaths ?? []);
const [newExtra, setNewExtra] = useState("");
const [saving, setSaving] = useState(false);
const [rescanning, setRescanning] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
const [picking, setPicking] = useState<string | null>(null);
async function pickFolder(start: string): Promise<string | null> {
try {
const r = await fetch("/api/pick-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
return typeof j.path === "string" && j.path ? j.path : null;
} catch (e) {
setError((e as Error).message);
return null;
}
}
async function browseMain() {
setPicking("main");
try {
const p = await pickFolder(draftMain);
if (p) setDraftMain(p);
} finally {
setPicking(null);
}
}
async function browseExtra(idx: number) {
setPicking(`extra-${idx}`);
try {
const p = await pickFolder(draftExtras[idx] ?? "");
if (p) updateExtra(idx, p);
} finally {
setPicking(null);
}
}
async function browseNewExtra() {
setPicking("new");
try {
const p = await pickFolder(newExtra);
if (p) {
if (!draftExtras.includes(p)) {
setDraftExtras((cur) => [...cur, p]);
setNewExtra("");
} else {
setNewExtra(p);
}
}
} finally {
setPicking(null);
}
}
const mainDirty = draftMain.trim() !== (settings.videoLibraryPath ?? "");
const extrasDirty =
draftExtras.length !== (settings.videoExtraPaths ?? []).length ||
draftExtras.some((v, i) => v !== (settings.videoExtraPaths ?? [])[i]);
const dirty = mainDirty || extrasDirty;
const hasAnyConfigured = !!settings.videoLibraryPath || (settings.videoExtraPaths ?? []).length > 0;
function addExtra() {
const v = newExtra.trim();
if (!v) return;
if (draftExtras.includes(v)) return;
setDraftExtras((cur) => [...cur, v]);
setNewExtra("");
}
function removeExtra(idx: number) {
setDraftExtras((cur) => cur.filter((_, i) => i !== idx));
}
function updateExtra(idx: number, value: string) {
setDraftExtras((cur) => cur.map((v, i) => i === idx ? value : v));
}
async function save() {
setSaving(true);
setError(null);
try {
if (mainDirty) await setVideoLibraryPath(draftMain);
if (extrasDirty) await setVideoExtraPaths(draftExtras);
const r = await fetch("/api/video-rescan", { method: "POST" });
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`);
setResult(j);
dispatchVideoStatusRefresh();
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
async function rescan(opts: { force?: boolean } = {}) {
setRescanning(true);
setError(null);
try {
const url = opts.force ? "/api/video-rescan?force=1" : "/api/video-rescan";
const r = await fetch(url, { method: "POST" });
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`);
setResult(j);
dispatchVideoStatusRefresh();
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setRescanning(false);
}
}
return (
<div className="py-2 space-y-5">
{/* Main folder ---------------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Main Library Folder</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursive scan. Expected to follow the same letter-bucket layout as the cover library, e.g.{" "}
<span className="font-mono">D:\JAV\A-E\A\AOZ-200Z.mp4</span>.
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={draftMain}
onChange={(e) => setDraftMain(e.target.value)}
placeholder="D:\JAV"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseMain}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "main" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
</div>
</div>
{/* Additional folders --------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Additional Folders</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Flat folders where videos sit directly inside (e.g. <span className="font-mono">E:\JAV\IBW-203.mp4</span>). Subfolders are still walked. One absolute path per row.
</div>
<div className="space-y-1.5">
{draftExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No additional folders.
</div>
) : (
draftExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => updateExtra(i, e.target.value)}
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newExtra}
onChange={(e) => setNewExtra(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addExtra(); } }}
placeholder="E:\JAV"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addExtra}
disabled={!newExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
</div>
</div>
{/* Action row ----------------------------------------------------- */}
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Rescan
</button>
<button
type="button"
onClick={() => rescan()}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Incremental — only re-walk folders whose mtime changed" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Re-scan Only
</button>
<button
type="button"
onClick={() => rescan({ force: true })}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Force full rescan — bypass dir-mtime cache (use after content edits without rename)" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-400/30 text-amber-200 hover:bg-amber-400/10 disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Force Full
</button>
</div>
{result && (
<div className="text-xs text-[var(--color-mint)] bg-[var(--color-mint)]/5 border border-[var(--color-mint)]/25 rounded-lg px-3 py-2 space-y-0.5">
<div>
Scanned in {result.elapsedMs}ms. <strong>{result.count}</strong> file{result.count === 1 ? "" : "s"} matched, <strong>{result.codes}</strong> unique code{result.codes === 1 ? "" : "s"}.
</div>
{result.rootsScanned.length > 0 && (
<div className="text-[10px] font-mono text-[var(--color-fg-muted)]">
roots: {result.rootsScanned.join(" · ")}
</div>
)}
</div>
)}
{error && (
<div className="text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Playback transcode mode --------------------------------------- */}
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Playback Transcoding (NVENC)
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Live HLS re-encode with NVENC, dropping B-frames to bypass Chromium&apos;s
H.264 sink reorder bug. Requires NVIDIA GPU + ffmpeg with
<span className="font-mono"> h264_nvenc</span>. Auto modes detect
whether transcoding is needed per file.
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
{
value: "off",
title: "Off",
desc: "Always serve the original file. Stuttery H.264 files will stutter.",
},
{
value: "always",
title: "Always Transcode",
desc: "Every file goes through NVENC HLS. Bullet-proof, slight quality loss.",
},
{
value: "auto-predicate",
title: "Auto · Predicate",
desc: "Probe codec on first play. Transcode only H.264 files with B-frames.",
},
{
value: "auto-runtime",
title: "Auto · Runtime",
desc: "Measure dropped frames in a brief pre-roll, decide and remember per file.",
},
] as const).map((opt) => {
const active = transcodeMode === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
setLocalTranscodeMode(opt.value);
startTranscode(() => {
void setTranscodeMode(opt.value);
});
}}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Part suffix patterns ------------------------------------------ */}
<PartSuffixPatterns />
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Captions, Loader2, Save, FolderOpen, CheckCircle2, AlertCircle, RefreshCw, Trash2, Sparkles } from "lucide-react";
import type { WhisperJavSettings as WhisperJavSettingsT } from "@/lib/db/appSettings";
import { setWhisperJavSettings } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { cn } from "@/lib/utils";
type Quality = WhisperJavSettingsT["quality"];
type SourceLang = WhisperJavSettingsT["sourceLanguage"];
type OutputMode = WhisperJavSettingsT["outputMode"];
type Sensitivity = WhisperJavSettingsT["sensitivity"];
type Location = WhisperJavSettingsT["outputLocation"];
interface VerifyResult {
ok: boolean;
version?: string;
resolvedPath?: string;
error?: string;
}
export function WhisperJavSettings() {
const { settings } = useSettings();
const w = settings.whisperjav;
const router = useRouter();
const { close: closeSettings } = useSettingsPanel();
const [, start] = useTransition();
const [draft, setDraft] = useState<WhisperJavSettingsT>(w);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verify, setVerify] = useState<VerifyResult | null>(null);
const [autodetected, setAutodetected] = useState(false);
useEffect(() => { setDraft(w); }, [w]);
// First-open autodetect — only when cliPath is empty AND no draft change.
useEffect(() => {
if (autodetected) return;
if (draft.cliPath) return;
setAutodetected(true);
void (async () => {
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autodetect: true }),
});
const data = (await r.json()) as VerifyResult;
if (data.ok && data.resolvedPath) {
setDraft((cur) => (cur.cliPath ? cur : { ...cur, cliPath: data.resolvedPath! }));
setVerify(data);
}
} catch { /* no autodetect available */ }
})();
}, [autodetected, draft.cliPath]);
const dirty = JSON.stringify(draft) !== JSON.stringify(w);
async function browse() {
try {
const r = await fetch("/api/pick-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose: "whisperjav" }),
});
const data = await r.json();
if (data.path) setDraft((cur) => ({ ...cur, cliPath: data.path }));
} catch { /* ignore */ }
}
async function runVerify() {
setVerifying(true);
setVerify(null);
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: draft.cliPath }),
});
const data = (await r.json()) as VerifyResult;
setVerify(data);
} catch (e) {
setVerify({ ok: false, error: (e as Error).message });
} finally {
setVerifying(false);
}
}
async function save() {
setSaving(true);
try {
await setWhisperJavSettings(draft);
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="py-2 space-y-5">
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
WhisperJAV Subtitle Generator
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Local AI subtitle generation. Configure the CLI path, then use{" "}
<span className="font-mono">Generate Subtitles</span> from the player&apos;s subtitle dropdown.
First run may download models (~12 GB).
</div>
</div>
{/* CLI path */}
<div>
<div className="text-sm font-medium mb-1">CLI Path</div>
<div className="flex items-center gap-2">
<input
type="text"
value={draft.cliPath}
onChange={(e) => setDraft({ ...draft, cliPath: e.target.value })}
placeholder="whisperjav (or absolute path)"
className="flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<button
type="button"
onClick={browse}
title="Browse for whisperjav.exe"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<FolderOpen className="w-4 h-4" /> Browse
</button>
<button
type="button"
onClick={runVerify}
disabled={verifying || !draft.cliPath.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Verify
</button>
</div>
{verify && (
<div className={cn(
"mt-2 text-xs rounded-lg px-3 py-2 border",
verify.ok
? "text-[var(--color-mint)] bg-[var(--color-mint)]/5 border-[var(--color-mint)]/25"
: "text-red-300 bg-red-500/5 border-red-500/25",
)}>
<div className="flex items-start gap-2">
{verify.ok
? <CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
: <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />}
<div className="min-w-0">
{verify.ok
? <>Detected: <strong>WhisperJAV {verify.version}</strong> at <span className="font-mono break-all">{verify.resolvedPath}</span></>
: <>Verify failed: {verify.error}</>}
</div>
</div>
</div>
)}
</div>
{/* Quality */}
<RadioGroup<Quality>
label="Quality"
value={draft.quality}
onChange={(v) => setDraft({ ...draft, quality: v })}
options={[
{ value: "fast", title: "Fast", desc: "Quickest pass; lower fidelity." },
{ value: "balanced", title: "Balanced", desc: "Default. Good speed/quality tradeoff." },
{ value: "qwen", title: "Best (Qwen)", desc: "Qwen3-ASR with JAV-tuned post-processing. Slow + heavy on VRAM." },
]}
/>
{/* Source language + Output mode */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<SourceLang>
label="Source Language"
value={draft.sourceLanguage}
onChange={(v) => setDraft({ ...draft, sourceLanguage: v })}
options={[
{ value: "japanese", title: "Japanese", desc: "" },
{ value: "korean", title: "Korean", desc: "" },
{ value: "chinese", title: "Chinese", desc: "" },
{ value: "english", title: "English", desc: "" },
]}
/>
<RadioGroup<OutputMode>
label="Output Mode"
value={draft.outputMode}
onChange={(v) => setDraft({ ...draft, outputMode: v })}
options={[
{ value: "native", title: "Native", desc: "Transcribe in source language." },
{ value: "direct-to-english", title: "Direct To English", desc: "Whisper-translate to English." },
]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<Sensitivity>
label="Sensitivity"
value={draft.sensitivity}
onChange={(v) => setDraft({ ...draft, sensitivity: v })}
options={[
{ value: "conservative", title: "Conservative", desc: "" },
{ value: "balanced", title: "Balanced", desc: "" },
{ value: "aggressive", title: "Aggressive", desc: "" },
]}
/>
<RadioGroup<Location>
label="Output Location"
value={draft.outputLocation}
onChange={(v) => setDraft({ ...draft, outputLocation: v })}
options={[
{ value: "beside-video", title: "Beside Video", desc: "Falls back to data folder if read-only." },
{ value: "data-folder", title: "Data Folder", desc: "data/generated-subtitles/<code>/" },
]}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={draft.noSignature}
onChange={(e) => setDraft({ ...draft, noSignature: e.target.checked })}
/>
Disable WhisperJAV signature cue at end of subtitles
</label>
<div>
<div className="text-sm font-medium mb-1">Job History Retention</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Days to keep failed and cancelled job temp folders for debugging.
Successful job folders are deleted immediately.{" "}
<span className="font-mono">0</span> = keep forever.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft.retentionDays}
onChange={(e) => setDraft({ ...draft, retentionDays: Math.max(0, Number(e.target.value) || 0) })}
className="w-24 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">days</span>
</div>
</div>
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)] flex-wrap">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
<Link
href="/subtitles/batch"
onClick={closeSettings}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Sparkles className="w-4 h-4 text-[var(--color-cyan)]" />
Open Batch Generator
</Link>
<ClearHistoryButton />
</div>
</div>
);
}
function ClearHistoryButton() {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
async function clearAll() {
if (!window.confirm("Clear all WhisperJAV job history? Temp folders for past jobs will be deleted.")) return;
setBusy(true);
setResult(null);
try {
const r = await fetch("/api/whisperjav-jobs", { method: "DELETE" });
const data = (await r.json()) as { rows?: number; dirs?: number };
setResult(`Removed ${data.rows ?? 0} row(s), ${data.dirs ?? 0} folder(s).`);
} catch (e) {
setResult(`Failed: ${(e as Error).message}`);
} finally {
setBusy(false);
}
}
return (
<>
<button
type="button"
onClick={clearAll}
disabled={busy}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Clear Job History
</button>
{result && <span className="text-xs text-[var(--color-fg-dim)]">{result}</span>}
</>
);
}
function RadioGroup<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
onChange: (v: T) => void;
options: Array<{ value: T; title: string; desc: string }>;
}) {
return (
<div>
<div className="text-sm font-medium mb-1">{label}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{options.map((opt) => {
const active = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors cursor-pointer",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
{opt.desc && (
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
)}
</button>
);
})}
</div>
</div>
);
}