Files
pinkudex/components/settings/PartSuffixPatterns.tsx
2026-05-26 22:46:00 +02:00

196 lines
6.9 KiB
TypeScript

"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Plus, Trash2, ArrowUp, ArrowDown, Save, RotateCcw, Hash } from "lucide-react";
import { setPartSuffixPatterns } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { cn } from "@/lib/utils";
const DEFAULT_PATTERNS = ["-cd{N}", ".part{N}", "_{N}", "_{L}"];
/**
* Validate a token-grammar pattern. Returns null if valid, or a short
* error message if not. Mirrors the rules in lib/video/partClassify.ts.
*/
function validateToken(source: string): string | null {
if (!source.trim()) return "empty";
let i = 0;
let captures = 0;
while (i < source.length) {
const c = source[i]!;
if (c === "{") {
const close = source.indexOf("}", i);
if (close < 0) return "unclosed {";
const tok = source.slice(i, close + 1);
if (tok !== "{N}" && tok !== "{L}") return `unknown token ${tok}`;
captures++;
i = close + 1;
} else {
i++;
}
}
if (captures === 0) return "needs {N} or {L}";
if (captures > 1) return "only one {N}/{L} per pattern";
return null;
}
/**
* Settings card body for editing the suffix patterns used to classify
* video parts vs. variants. Uses option A1 (token grammar) from the
* mockup — each row is a single editable pattern with reorder/delete.
*/
export function PartSuffixPatterns() {
const { settings } = useSettings();
const router = useRouter();
const [draft, setDraft] = useState<string[]>(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
// Keep the editor in sync with server-side updates (e.g. another tab).
useEffect(() => {
setDraft(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
}, [settings.partSuffixPatterns]);
const errors = draft.map(validateToken);
const hasErrors = errors.some((e) => e != null);
const dirty =
draft.length !== (settings.partSuffixPatterns ?? []).length ||
draft.some((v, i) => v !== (settings.partSuffixPatterns ?? [])[i]);
function update(i: number, value: string) {
setDraft((cur) => cur.map((v, idx) => (idx === i ? value : v)));
}
function remove(i: number) {
setDraft((cur) => cur.filter((_, idx) => idx !== i));
}
function add() {
setDraft((cur) => [...cur, ""]);
}
function move(i: number, dir: -1 | 1) {
setDraft((cur) => {
const j = i + dir;
if (j < 0 || j >= cur.length) return cur;
const next = cur.slice();
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
}
function resetDefaults() {
setDraft(DEFAULT_PATTERNS.slice());
}
async function save() {
if (hasErrors) return;
setSaving(true);
try {
// Strip empties on save (the action also trims, but UX clarity).
const cleaned = draft.map((s) => s.trim()).filter(Boolean);
await setPartSuffixPatterns(cleaned);
startTransition(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Hash className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Part Suffix Patterns
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Match the trailing portion of a filename stem to identify
sequential parts.{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{N}"}</span>{" "}
captures digits,{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{L}"}</span>{" "}
captures a single letter (A=1, B=2). All other characters are
literal. Files in a code group that match nothing become
<em> variants</em> of the part they share a stem prefix with.
</div>
</div>
<ol className="space-y-1.5">
{draft.map((p, i) => {
const err = errors[i];
return (
<li key={i} className="flex items-center gap-2">
<input
type="text"
value={p}
onChange={(e) => update(i, e.target.value)}
placeholder="-cd{N}"
className={cn(
"flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none",
err
? "border border-red-500/50 focus:border-red-400"
: "focus:border-[var(--color-cyan)]",
)}
/>
<button
type="button"
onClick={() => move(i, -1)}
disabled={i === 0}
title="Move up"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => move(i, 1)}
disabled={i === draft.length - 1}
title="Move down"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => remove(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
);
})}
</ol>
{hasErrors && (
<div className="text-[11px] text-red-300">
{errors.map((e, i) => (e ? `Line ${i + 1}: ${e}` : null)).filter(Boolean).join(" · ")}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Plus className="w-4 h-4" /> Add Pattern
</button>
<button
type="button"
onClick={resetDefaults}
title="Reset to built-in defaults"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-muted)]"
>
<RotateCcw className="w-4 h-4" /> Reset to Defaults
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving || hasErrors}
className="ml-auto inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Reclassify
</button>
</div>
</div>
);
}