commit 3c7e40e62da2a8e7732bd91d19da797fcb385092
parent 21f6ad831df8ca418ae0d2ef65bb995268b95999
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:12:07 -0400
tags S3.2: the per-site overlay tab
/sites/<id>/tags, beside Charts and Search aliases. It edits presentation —
label, group label, colour, order, visibility — and rules that fire only on this
site's build.
WHAT IT DOES NOT OFFER IS THE POINT. mergeTagDefs is a field-wise overlay that
APPENDS rules, so there is no control here that deletes a corpus rule, and none
that touches an assignment at all: an assignment is a fact about a video, not a
presentation choice. A site that wants a tag gone hides the chip. Corpus rules
are printed read-only beside the site's own so the difference is visible rather
than implied.
Visibility is a THREE-state select (inherit / shown / hidden) rather than a
checkbox, because a site that has said nothing about a tag must not start
writing `hidden: false` into its file — and an overlay row that says nothing at
all is dropped on save rather than written as a row of empty strings, which
would blank the corpus label through the merge.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
3 files changed, 593 insertions(+), 1 deletion(-)
diff --git a/editor/app/sites/[siteId]/components/SiteTabs.tsx b/editor/app/sites/[siteId]/components/SiteTabs.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
-// Settings | Charts | Search aliases | Publish.
+// Settings | Charts | Search aliases | Tags | Publish.
//
// A CLIENT component on purpose. Next 16 layouts are cached during navigation
// and DO NOT RERENDER — they cannot read the pathname and would not re-evaluate
@@ -27,6 +27,11 @@ export function SiteTabs({ siteId }: { siteId: string }) {
label="Search aliases"
/>
<Tab
+ href={`/sites/${siteId}/tags`}
+ active={segment === "tags"}
+ label="Tags"
+ />
+ <Tab
href={`/sites/${siteId}/publish`}
active={segment === "publish"}
label="Publish"
diff --git a/editor/app/sites/[siteId]/tags/page.tsx b/editor/app/sites/[siteId]/tags/page.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { readGlobalTags, readSiteTags } from "../../../../lib/tagsStore";
+import { SiteTagsClient } from "../../components/SiteTagsClient";
+import { getSiteCached } from "../lib/siteCache";
+
+export const dynamic = "force-dynamic";
+// → "Tags — <id> — Sites" through the layout's title template.
+export const metadata: Metadata = { title: "Tags" };
+
+// A SITE MAY DRESS A TAG UP; IT MAY NOT GUT IT.
+//
+// The corpus file is authoritative for what a tag IS and for which videos carry
+// it. This tab is presentation — label, group label, colour, order, hidden —
+// plus rules that fire on this site's build only. mergeTagDefs is a field-wise
+// overlay that APPENDS rules: there is no way to delete a corpus rule or a
+// corpus assignment from here, and the UI deliberately does not offer one (an
+// assignment is a fact about a video, not a presentation choice). Hide the chip
+// instead — that is what `hidden` is for.
+export default async function SiteTagsPage({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ if (!getSiteCached(siteId)) notFound();
+ const paths = getPaths();
+ const globalConfig = readGlobalTags(paths);
+ const siteConfig = readSiteTags(paths, siteId);
+ return (
+ <section className="flex flex-col gap-4">
+ <p className="max-w-3xl text-sm text-muted-foreground">
+ The corpus vocabulary is on <Link href="/tags" className="underline">Tags</Link>;
+ this tab is how <strong>{siteId}</strong> shows it. Relabel, recolour,
+ reorder or hide a tag here, or give it an extra rule that fires only on
+ this site's build. A blank field inherits. Corpus rules are listed
+ read-only: a site can add to them, never remove one — and it can never
+ remove an assignment, which is a fact about a video. Changes bake into
+ the site's next export build.
+ </p>
+ <SiteTagsClient
+ key={siteId}
+ siteId={siteId}
+ globalTags={globalConfig.tags}
+ siteTags={siteConfig.tags}
+ />
+ </section>
+ );
+}
diff --git a/editor/app/sites/components/SiteTagsClient.tsx b/editor/app/sites/components/SiteTagsClient.tsx
@@ -0,0 +1,536 @@
+"use client";
+
+// One site's overlay on the corpus vocabulary.
+//
+// Two lists, and the difference between them is the whole point:
+//
+// OVERLAYS — one row per corpus tag. Every field is optional and a blank one
+// inherits, so the file holds only what this site actually changed. The tag's
+// corpus rules are printed read-only; the site may APPEND a rule and remove
+// only the rules it added itself. There is no control that deletes a corpus
+// rule or a corpus assignment, because mergeTagDefs cannot express either.
+//
+// SITE-ONLY TAGS — ids the corpus does not define at all, edited in full.
+//
+// Assignments never appear here. An assignment is a fact about a video; a site
+// hides a chip, it does not erase a fact.
+
+import { useMemo, useState, useTransition } from "react";
+import {
+ TAG_ID_RE,
+ type CuratedTagDef,
+ type CuratedTagRule,
+ type CuratedTagRuleKind,
+} from "yt-dlp-transcript-common/lib/curatedTags";
+import { saveSiteTagDefsAction } from "../../tags/actions";
+
+const KINDS: CuratedTagRuleKind[] = ["metadata", "chat-author", "caption"];
+
+type RuleRow = CuratedTagRule & { key: string; channelsText: string };
+
+type OverlayRow = {
+ id: string;
+ label: string;
+ groupLabel: string;
+ color: string;
+ order: string;
+ // Three states, not two: inherit / shown / hidden. A site that has said
+ // nothing about a tag must not start writing `hidden: false` into its file.
+ hidden: "" | "true" | "false";
+ rules: RuleRow[];
+};
+
+let keySeq = 0;
+const mkKey = () => `s${keySeq++}`;
+
+function toRuleRow(r: CuratedTagRule): RuleRow {
+ return { ...r, key: mkKey(), channelsText: (r.channels ?? []).join(", ") };
+}
+
+function toRule(r: RuleRow): CuratedTagRule {
+ const channels = r.channelsText
+ .split(",")
+ .map((c) => c.trim())
+ .filter(Boolean);
+ return {
+ id: r.id.trim() || "site",
+ kind: r.kind,
+ pattern: r.pattern,
+ enabled: r.enabled !== false,
+ ...(channels.length > 0 ? { channels } : {}),
+ };
+}
+
+function overlayRowFor(id: string, site: CuratedTagDef | undefined): OverlayRow {
+ return {
+ id,
+ label: site?.label ?? "",
+ groupLabel: site?.groupLabel ?? "",
+ color: site?.color ?? "",
+ order: site?.order === undefined ? "" : String(site.order),
+ hidden: site?.hidden === undefined ? "" : site.hidden ? "true" : "false",
+ rules: (site?.rules ?? []).map(toRuleRow),
+ };
+}
+
+// An overlay row is worth writing only when it says something.
+function overlayToDef(row: OverlayRow): CuratedTagDef | null {
+ const order = Number(row.order);
+ const def: CuratedTagDef = {
+ id: row.id,
+ // `label` is required on the type; the corpus label is what the merge keeps
+ // when the site does not override it, so an untouched overlay is dropped
+ // entirely below rather than written with an empty label.
+ label: row.label.trim(),
+ ...(row.groupLabel.trim() ? { groupLabel: row.groupLabel.trim() } : {}),
+ ...(row.color.trim() ? { color: row.color.trim() } : {}),
+ ...(row.order.trim() !== "" && Number.isFinite(order) ? { order } : {}),
+ ...(row.hidden === "" ? {} : { hidden: row.hidden === "true" }),
+ ...(row.rules.length > 0 ? { rules: row.rules.map(toRule) } : {}),
+ };
+ const saysSomething =
+ def.label !== "" ||
+ def.groupLabel !== undefined ||
+ def.color !== undefined ||
+ def.order !== undefined ||
+ def.hidden !== undefined ||
+ (def.rules?.length ?? 0) > 0;
+ if (!saysSomething) return null;
+ // mergeTagDefs only applies fields that are present, so an overlay with no
+ // label must not carry `label: ""` — that would blank the corpus label.
+ if (def.label === "") delete (def as { label?: string }).label;
+ return def;
+}
+
+export function SiteTagsClient({
+ siteId,
+ globalTags,
+ siteTags,
+}: {
+ siteId: string;
+ globalTags: CuratedTagDef[];
+ siteTags: CuratedTagDef[];
+}) {
+ const globalIds = useMemo(
+ () => new Set(globalTags.map((t) => t.id)),
+ [globalTags],
+ );
+ const [overlays, setOverlays] = useState<OverlayRow[]>(() =>
+ globalTags.map((g) =>
+ overlayRowFor(
+ g.id,
+ siteTags.find((s) => s.id === g.id),
+ ),
+ ),
+ );
+ const [ownRows, setOwnRows] = useState<OverlayRow[]>(() =>
+ siteTags
+ .filter((s) => !globalIds.has(s.id))
+ .map((s) => overlayRowFor(s.id, s)),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [saving, startSave] = useTransition();
+
+ const touch = () => {
+ setDirty(true);
+ setSaved(false);
+ setError(null);
+ };
+
+ const save = () => {
+ const bad = ownRows.find((r) => !TAG_ID_RE.test(r.id.trim().toLowerCase()));
+ if (bad) {
+ setError(`"${bad.id}" is not a valid tag id`);
+ return;
+ }
+ const defs = [
+ ...overlays.map(overlayToDef).filter((d): d is CuratedTagDef => d !== null),
+ ...ownRows
+ .map((r) => overlayToDef({ ...r, id: r.id.trim().toLowerCase() }))
+ .filter((d): d is CuratedTagDef => d !== null)
+ // A site-only tag with no label at all is unusable as a chip; give it
+ // its id, which is what sanitizeTagsConfig would do anyway.
+ .map((d) => ({ ...d, label: d.label || d.id })),
+ ];
+ startSave(async () => {
+ const result = await saveSiteTagDefsAction(siteId, defs);
+ if (result.ok) {
+ setDirty(false);
+ setSaved(true);
+ } else {
+ setError(result.error);
+ }
+ });
+ };
+
+ const patchOverlay = (
+ list: "global" | "own",
+ id: string,
+ next: Partial<OverlayRow>,
+ ) => {
+ const set = list === "global" ? setOverlays : setOwnRows;
+ set((rs) => rs.map((r) => (r.id === id ? { ...r, ...next } : r)));
+ touch();
+ };
+
+ return (
+ <div className="flex flex-col gap-6" data-testid="site-tags-client">
+ <div className="flex items-center gap-3">
+ <button
+ type="button"
+ onClick={save}
+ disabled={!dirty || saving}
+ aria-label="save site tags"
+ className="rounded border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
+ >
+ {saving ? "Saving…" : "Save"}
+ </button>
+ {saved && !dirty && (
+ <span className="text-xs text-success" data-testid="site-tags-saved">
+ Saved
+ </span>
+ )}
+ {error && (
+ <span role="status" className="text-xs text-warning">
+ {error}
+ </span>
+ )}
+ </div>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="text-lg font-semibold">Corpus tags on this site</h2>
+ {globalTags.length === 0 ? (
+ <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
+ The corpus has no tags yet.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3" aria-label="tag overlays">
+ {globalTags.map((g) => {
+ const row = overlays.find((o) => o.id === g.id)!;
+ return (
+ <li
+ key={g.id}
+ data-testid="tag-overlay"
+ data-tag={g.id}
+ className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3"
+ >
+ <div className="flex flex-wrap items-end gap-3">
+ <span className="pb-1.5 font-mono text-sm">{g.id}</span>
+ <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground">
+ Label
+ <input
+ value={row.label}
+ onChange={(e) =>
+ patchOverlay("global", g.id, { label: e.target.value })
+ }
+ placeholder={g.label}
+ aria-label={`label for ${g.id}`}
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[8rem] flex-col gap-1 text-xs text-muted-foreground">
+ Group label
+ <input
+ value={row.groupLabel}
+ onChange={(e) =>
+ patchOverlay("global", g.id, {
+ groupLabel: e.target.value,
+ })
+ }
+ placeholder={g.groupLabel ?? ""}
+ aria-label={`group label for ${g.id}`}
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex w-24 flex-col gap-1 text-xs text-muted-foreground">
+ Colour
+ <input
+ value={row.color}
+ onChange={(e) =>
+ patchOverlay("global", g.id, { color: e.target.value })
+ }
+ placeholder={g.color ?? "#b48ead"}
+ aria-label={`colour for ${g.id}`}
+ className="rounded border border-border bg-card px-2 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex w-20 flex-col gap-1 text-xs text-muted-foreground">
+ Order
+ <input
+ value={row.order}
+ onChange={(e) =>
+ patchOverlay("global", g.id, { order: e.target.value })
+ }
+ placeholder={g.order === undefined ? "" : String(g.order)}
+ aria-label={`order for ${g.id}`}
+ inputMode="numeric"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Visibility
+ <select
+ value={row.hidden}
+ onChange={(e) =>
+ patchOverlay("global", g.id, {
+ hidden: e.target.value as OverlayRow["hidden"],
+ })
+ }
+ aria-label={`visibility for ${g.id}`}
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ >
+ <option value="">
+ Inherit ({g.hidden ? "hidden" : "shown"})
+ </option>
+ <option value="false">Shown here</option>
+ <option value="true">Hidden here</option>
+ </select>
+ </label>
+ </div>
+
+ {(g.rules ?? []).length > 0 && (
+ <p className="text-[11px] text-muted-foreground">
+ Corpus rules (read-only here):{" "}
+ {(g.rules ?? [])
+ .map(
+ (r) =>
+ `${r.id} · ${r.kind} · /${r.pattern}/${r.enabled === false ? " (disabled)" : ""}`,
+ )
+ .join(" · ")}
+ </p>
+ )}
+
+ <SiteRules
+ rows={row.rules}
+ onChange={(rules) => patchOverlay("global", g.id, { rules })}
+ tagId={g.id}
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </section>
+
+ <section className="flex flex-col gap-3">
+ <div className="flex items-center gap-3">
+ <h2 className="text-lg font-semibold">Site-only tags</h2>
+ <button
+ type="button"
+ aria-label="add site-only tag"
+ onClick={() => {
+ setOwnRows((rs) => [
+ ...rs,
+ {
+ id: "",
+ label: "",
+ groupLabel: "",
+ color: "",
+ order: "",
+ hidden: "",
+ rules: [],
+ },
+ ]);
+ touch();
+ }}
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-muted"
+ >
+ + Add
+ </button>
+ </div>
+ {ownRows.length === 0 ? (
+ <p className="text-xs text-muted-foreground">
+ None. A site-only tag exists on this site and nowhere else.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3" aria-label="site-only tags">
+ {ownRows.map((row, i) => (
+ <li
+ key={`own-${i}`}
+ data-testid="site-only-tag"
+ className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3"
+ >
+ <div className="flex flex-wrap items-end gap-3">
+ <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground">
+ Id
+ <input
+ value={row.id}
+ onChange={(e) => {
+ const id = e.target.value;
+ setOwnRows((rs) =>
+ rs.map((r, j) => (j === i ? { ...r, id } : r)),
+ );
+ touch();
+ }}
+ aria-label="site-only tag id"
+ className="rounded border border-border bg-card px-2 py-1 font-mono text-sm text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground">
+ Label
+ <input
+ value={row.label}
+ onChange={(e) => {
+ const label = e.target.value;
+ setOwnRows((rs) =>
+ rs.map((r, j) => (j === i ? { ...r, label } : r)),
+ );
+ touch();
+ }}
+ aria-label="site-only tag label"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <button
+ type="button"
+ onClick={() => {
+ setOwnRows((rs) => rs.filter((_, j) => j !== i));
+ touch();
+ }}
+ aria-label={`remove site-only tag ${row.id || "new"}`}
+ className="ml-auto rounded border border-border px-2 py-1 text-xs hover:bg-muted"
+ >
+ ×
+ </button>
+ </div>
+ <SiteRules
+ rows={row.rules}
+ tagId={row.id || `own-${i}`}
+ onChange={(rules) => {
+ setOwnRows((rs) =>
+ rs.map((r, j) => (j === i ? { ...r, rules } : r)),
+ );
+ touch();
+ }}
+ />
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ </div>
+ );
+}
+
+function SiteRules({
+ rows,
+ tagId,
+ onChange,
+}: {
+ rows: RuleRow[];
+ tagId: string;
+ onChange: (rules: RuleRow[]) => void;
+}) {
+ return (
+ <div className="flex flex-col gap-2">
+ {rows.map((rule) => (
+ <div
+ key={rule.key}
+ data-testid="site-tag-rule"
+ className="flex flex-wrap items-end gap-2 rounded border border-border/60 bg-background/40 px-2 py-2"
+ >
+ <label className="flex w-24 flex-col gap-1 text-[11px] text-muted-foreground">
+ Rule id
+ <input
+ value={rule.id}
+ onChange={(e) =>
+ onChange(
+ rows.map((r) =>
+ r.key === rule.key ? { ...r, id: e.target.value } : r,
+ ),
+ )
+ }
+ aria-label={`site rule id for ${tagId}`}
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
+ Kind
+ <select
+ value={rule.kind}
+ onChange={(e) =>
+ onChange(
+ rows.map((r) =>
+ r.key === rule.key
+ ? { ...r, kind: e.target.value as CuratedTagRuleKind }
+ : r,
+ ),
+ )
+ }
+ aria-label={`site rule kind for ${tagId}`}
+ className="rounded border border-border bg-card px-1.5 py-1 text-xs text-foreground"
+ >
+ {KINDS.map((k) => (
+ <option key={k} value={k}>
+ {k}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex min-w-[12rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
+ Pattern
+ <input
+ value={rule.pattern}
+ onChange={(e) =>
+ onChange(
+ rows.map((r) =>
+ r.key === rule.key ? { ...r, pattern: e.target.value } : r,
+ ),
+ )
+ }
+ aria-label={`site rule pattern for ${tagId}`}
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[10rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
+ Channels
+ <input
+ value={rule.channelsText}
+ onChange={(e) =>
+ onChange(
+ rows.map((r) =>
+ r.key === rule.key
+ ? { ...r, channelsText: e.target.value }
+ : r,
+ ),
+ )
+ }
+ aria-label={`site rule channels for ${tagId}`}
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <button
+ type="button"
+ onClick={() => onChange(rows.filter((r) => r.key !== rule.key))}
+ aria-label={`remove site rule ${rule.id} from ${tagId}`}
+ className="rounded border border-border px-1.5 py-1 text-xs hover:bg-muted"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ <button
+ type="button"
+ onClick={() =>
+ onChange([
+ ...rows,
+ {
+ key: mkKey(),
+ id: `site${rows.length + 1}`,
+ kind: "metadata",
+ pattern: "",
+ enabled: true,
+ channelsText: "",
+ },
+ ])
+ }
+ aria-label={`add site rule to ${tagId}`}
+ className="self-start rounded border border-border px-2 py-1 text-xs hover:bg-muted"
+ >
+ + Add site rule
+ </button>
+ </div>
+ );
+}