"use client";
// Authoring UI for search aliases. Two independent sections — the global
// dictionary and the site's overrides — each edits a list of concept rows and
// saves the whole list via a server action. Triggers
// are entered comma-separated and shown back as chips. Suggestions are
// validated as regex when the row is in regex mode, mirroring the viewer's
// leaf-input guard.
import { useRef, useState } from "react";
import { Button } from "yt-dlp-transcript-common/components/ui/button";
import { Input } from "yt-dlp-transcript-common/components/ui/input";
import { Checkbox } from "yt-dlp-transcript-common/components/ui/checkbox";
import {
slugifyAliasId,
type AliasConfig,
type SearchAlias,
} from "yt-dlp-transcript-common/lib/searchAliases";
import { saveGlobalAliasesAction, saveSiteAliasesAction } from "../lib/aliasesActions";
type Row = {
key: string;
id: string;
label: string;
triggersText: string;
suggestion: string;
useRegex: boolean;
enabled: boolean;
note: string;
};
function toRow(a: SearchAlias, key: string): Row {
return {
key,
id: a.id,
label: a.label,
triggersText: a.triggers.join(", "),
suggestion: a.suggestion,
useRegex: a.useRegex,
enabled: a.enabled !== false,
note: a.note ?? "",
};
}
function parseTriggers(text: string): string[] {
return text
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
function toAlias(r: Row): SearchAlias {
const triggers = parseTriggers(r.triggersText);
const label = r.label.trim();
const note = r.note.trim();
return {
id: r.id.trim() || slugifyAliasId(label),
label,
triggers,
suggestion: r.suggestion,
useRegex: r.useRegex,
enabled: r.enabled,
...(note ? { note } : {}),
};
}
function isValidRegex(s: string): boolean {
try {
new RegExp(s);
return true;
} catch {
return false;
}
}
export function EditorAliasesClient({
globalConfig,
siteConfig,
siteId,
}: {
globalConfig: AliasConfig;
siteConfig: AliasConfig;
siteId: string;
}) {
return (
saveGlobalAliasesAction(cfg)}
testid="global"
/>
saveSiteAliasesAction(siteId, cfg)}
testid="site"
/>
);
}
function AliasSection({
title,
subtitle,
initial,
onSave,
testid,
}: {
title: string;
subtitle: string;
initial: SearchAlias[];
onSave: (config: AliasConfig) => Promise<{ ok: true }>;
testid: string;
}) {
const keyRef = useRef(0);
const mkKey = () => `r${keyRef.current++}`;
const [rows, setRows] = useState(() =>
initial.map((a) => toRow(a, mkKey())),
);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const patch = (key: string, next: Partial) => {
setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...next } : r)));
setDirty(true);
setSaved(false);
};
const remove = (key: string) => {
setRows((rs) => rs.filter((r) => r.key !== key));
setDirty(true);
setSaved(false);
};
const add = () => {
setRows((rs) => [
...rs,
{
key: mkKey(),
id: "",
label: "",
triggersText: "",
suggestion: "",
useRegex: true,
enabled: true,
note: "",
},
]);
setDirty(true);
setSaved(false);
};
const anyRegexInvalid = rows.some(
(r) => r.useRegex && r.suggestion.trim() !== "" && !isValidRegex(r.suggestion),
);
const save = async () => {
setSaving(true);
try {
// Drop rows missing a label, triggers, or a suggestion — coerce would too.
const aliases = rows
.map(toAlias)
.filter((a) => a.label && a.triggers.length > 0 && a.suggestion);
await onSave({ aliases });
setDirty(false);
setSaved(true);
} finally {
setSaving(false);
}
};
return (
{saved && !dirty && (
Saved
)}
{rows.length === 0 ? (
No aliases yet. Add one to start suggesting better queries.
) : (
{rows.map((r) => (
patch(r.key, next)}
onRemove={() => remove(r.key)}
testid={testid}
/>
))}
)}
);
}
function AliasRow({
row,
onPatch,
onRemove,
testid,
}: {
row: Row;
onPatch: (next: Partial) => void;
onRemove: () => void;
testid: string;
}) {
const triggers = parseTriggers(row.triggersText);
const regexInvalid =
row.useRegex && row.suggestion.trim() !== "" && !isValidRegex(row.suggestion);
return (
{triggers.length > 0 && (
{triggers.map((t, i) => (
{t}
))}
)}
{regexInvalid && (
Invalid regex
)}
{triggers[0] && row.suggestion && (
Preview: typing {triggers[0]} suggests{" "}
{row.suggestion}
)}
);
}
function cnRow(enabled: boolean): string {
return [
"flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3",
enabled ? "" : "opacity-60",
]
.filter(Boolean)
.join(" ");
}