"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(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 (
Part Suffix Patterns
Match the trailing portion of a filename stem to identify sequential parts.{" "} {"{N}"}{" "} captures digits,{" "} {"{L}"}{" "} captures a single letter (A=1, B=2…). All other characters are literal. Files in a code group that match nothing become variants of the part they share a stem prefix with.
    {draft.map((p, i) => { const err = errors[i]; return (
  1. 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)]", )} />
  2. ); })}
{hasErrors && (
{errors.map((e, i) => (e ? `Line ${i + 1}: ${e}` : null)).filter(Boolean).join(" · ")}
)}
); }