Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Download, Upload, Loader2, AlertTriangle, Archive, PackageOpen } from "lucide-react";
|
||||
|
||||
export function BackupButtons() {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const zipFileRef = useRef<HTMLInputElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importingZip, setImportingZip] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportingAll, setExportingAll] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
// Synchronous guard: setImporting() updates state asynchronously, so a
|
||||
// rapid second invocation could slip through before importing=true is
|
||||
// visible in the next render. The ref blocks that window.
|
||||
const importingRef = useRef(false);
|
||||
const importingZipRef = useRef(false);
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/backup/export");
|
||||
if (!res.ok) throw new Error(`Export failed (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
a.download = `pinkudex-backup-${stamp}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage({ kind: "ok", text: "Backup downloaded." });
|
||||
} catch (e) {
|
||||
setMessage({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportAll() {
|
||||
setExportingAll(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Direct navigation so the browser owns the download (and shows its
|
||||
// own progress bar, ETA, pause/cancel) — fetch+blob would buffer the
|
||||
// whole zip in memory, which is fatal for multi-GB libraries.
|
||||
const a = document.createElement("a");
|
||||
a.href = "/api/backup/library-export";
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setMessage({ kind: "ok", text: "Library export started — see browser downloads." });
|
||||
} catch (e) {
|
||||
setMessage({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
setExportingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(file: File) {
|
||||
if (importingRef.current) return;
|
||||
// Claim the lock BEFORE confirm() so a second invocation triggered
|
||||
// while the dialog is still open (e.g. rapid double-pick of the same
|
||||
// file) hits the early return instead of slipping through the race
|
||||
// window between confirm and setImporting.
|
||||
importingRef.current = true;
|
||||
if (!confirm(
|
||||
"Importing will REPLACE all existing actresses, covers, categories, tags, collections and settings with the contents of this backup.\n\nThis cannot be undone. Continue?",
|
||||
)) {
|
||||
importingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await fetch("/api/backup/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text,
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error ?? `Import failed (${res.status})`);
|
||||
const totals = Object.entries(j.counts ?? {})
|
||||
.filter(([, n]) => (n as number) > 0)
|
||||
.map(([t, n]) => `${t}: ${n}`)
|
||||
.join(", ");
|
||||
setMessage({ kind: "ok", text: `Import complete. ${totals || "(empty)"}` });
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setMessage({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
importingRef.current = false;
|
||||
setImporting(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportZip(file: File) {
|
||||
if (importingZipRef.current) return;
|
||||
// Same race-window fix as handleImport — claim the lock before the
|
||||
// confirm() dialog opens.
|
||||
importingZipRef.current = true;
|
||||
if (!confirm(
|
||||
"Restoring will REPLACE the database AND your library/, data/thumbs/, data/portraits/, data/category-covers/ folders.\n\n" +
|
||||
"Existing folders will be renamed to *.pre-restore-<timestamp>/ for manual rollback. The database is snapshotted first.\n\n" +
|
||||
"This can take a long time for multi-GB archives. Continue?",
|
||||
)) {
|
||||
importingZipRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setImportingZip(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Stream the file as the request body so multi-GB uploads don't have
|
||||
// to be buffered into memory before sending. The route streams it
|
||||
// straight to disk on the server side.
|
||||
const res = await fetch("/api/backup/library-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/zip" },
|
||||
body: file,
|
||||
// @ts-expect-error - Node fetch undici extension; lets the body stream.
|
||||
duplex: "half",
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error ?? `Restore failed (${res.status})`);
|
||||
const totals = Object.entries(j.counts ?? {})
|
||||
.filter(([, n]) => (n as number) > 0)
|
||||
.map(([t, n]) => `${t}: ${n}`)
|
||||
.join(", ");
|
||||
const mediaParts = (j.mediaRestored ?? []).join(", ");
|
||||
setMessage({
|
||||
kind: "ok",
|
||||
text: `Restore complete. DB — ${totals || "(empty)"}. Media — ${mediaParts || "(none)"}. Old folders kept as *.pre-restore-* for rollback.`,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setMessage({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
importingZipRef.current = false;
|
||||
setImportingZip(false);
|
||||
if (zipFileRef.current) zipFileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="text-sm font-medium mb-1">Backup & Restore</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mb-3">
|
||||
<strong>Export backup</strong> — metadata only (JSON). Fast, small, safe to email.
|
||||
<br />
|
||||
<strong>Export full library</strong> — zip of <span className="font-mono">library/</span>, <span className="font-mono">data/thumbs/</span>, <span className="font-mono">data/portraits/</span>, <span className="font-mono">data/category-covers/</span> plus <span className="font-mono">database.json</span>. Skips <span className="font-mono">library/.superseded/</span>. Can be many GB; browser shows progress.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Export backup
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportAll}
|
||||
disabled={exportingAll}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{exportingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Archive className="w-4 h-4" />}
|
||||
Export full library
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
|
||||
>
|
||||
{importing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Import backup…
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImport(f); }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zipFileRef.current?.click()}
|
||||
disabled={importingZip}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
|
||||
>
|
||||
{importingZip ? <Loader2 className="w-4 h-4 animate-spin" /> : <PackageOpen className="w-4 h-4" />}
|
||||
Restore full library…
|
||||
</button>
|
||||
<input
|
||||
ref={zipFileRef}
|
||||
type="file"
|
||||
accept="application/zip,.zip"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImportZip(f); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2 text-xs ${
|
||||
message.kind === "ok" ? "text-[var(--color-mint)]" : "text-red-300"
|
||||
}`}
|
||||
>
|
||||
{message.kind === "err" && <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<span className="break-words">{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Reclassify
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"><sha>.webp</code>
|
||||
files to the new <code className="font-mono"><CODE>-<sha>.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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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 & 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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's subtitle dropdown.
|
||||
First run may download models (~1–2 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user