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