commit b57aca2ccd9644e0fc3d8214a25c53fbaa32833f
parent 3c634e30cdb3395df44e0dcf87da22532eb5d6b2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 30 Aug 2026 12:56:08 -0400
sites: a site has tabs, and Charts and Search aliases are two of them
/sites/[siteId] is a layout now — the breadcrumb, the site's title and a
Settings | Charts | Search aliases switch, with the active tab decided in a
client component because Next 16 layouts do not rerender. The site is the PATH:
each tab reads params.siteId, and the sidebar picker reads the same path through
a new pure siteIdFromPathname(), writes it to localStorage so Dashboard and
Channels follow, and navigates to the same tab of the other site on change.
/charts and /aliases are gone; their clients and actions moved under
sites/components and sites/lib. Both redirect 307 — a ?site=<id> bookmark lands
on that site's tab (a `has` rule whose value regex is SITE_ID_RE, so
?site=__all__ falls through to /sites). The aliases tab keeps BOTH sections:
the global dictionary and the site's overrides, because a per-site alias shadows
the global one and the overlay alone would hide what it overlays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
24 files changed, 839 insertions(+), 643 deletions(-)
diff --git a/editor/app/aliases/EditorAliasesClient.tsx b/editor/app/aliases/EditorAliasesClient.tsx
@@ -1,361 +0,0 @@
-"use client";
-
-// Authoring UI for search aliases. Two independent sections — the global
-// dictionary and (when a site is selected) that 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 "./actions";
-
-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 | null;
- siteId: string | null;
-}) {
- return (
- <div className="flex flex-col gap-8">
- <AliasSection
- title="Global"
- subtitle="Suggested on every site."
- initial={globalConfig.aliases}
- onSave={(cfg) => saveGlobalAliasesAction(cfg)}
- testid="global"
- />
- {siteId && siteConfig && (
- <AliasSection
- title={`This site — ${siteId}`}
- subtitle="Adds to the global list. An entry that reuses a global id overrides it (set it disabled to hide that global alias here)."
- initial={siteConfig.aliases}
- onSave={(cfg) => saveSiteAliasesAction(siteId, cfg)}
- testid="site"
- />
- )}
- </div>
- );
-}
-
-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<Row[]>(() =>
- 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<Row>) => {
- 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 (
- <section
- className="flex flex-col gap-3"
- data-testid={`alias-section-${testid}`}
- >
- <div className="flex items-baseline justify-between gap-4">
- <div>
- <h2 className="text-lg font-semibold">{title}</h2>
- <p className="text-xs text-muted-foreground">{subtitle}</p>
- </div>
- <div className="flex items-center gap-2">
- {saved && !dirty && (
- <span className="text-xs text-success" data-testid={`alias-saved-${testid}`}>
- Saved
- </span>
- )}
- <Button
- type="button"
- size="sm"
- onClick={save}
- disabled={!dirty || saving || anyRegexInvalid}
- data-testid={`alias-save-${testid}`}
- >
- {saving ? "Saving…" : "Save"}
- </Button>
- </div>
- </div>
-
- {rows.length === 0 ? (
- <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
- No aliases yet. Add one to start suggesting better queries.
- </p>
- ) : (
- <ul className="flex flex-col gap-3">
- {rows.map((r) => (
- <AliasRow
- key={r.key}
- row={r}
- onPatch={(next) => patch(r.key, next)}
- onRemove={() => remove(r.key)}
- testid={testid}
- />
- ))}
- </ul>
- )}
-
- <div>
- <Button
- type="button"
- size="sm"
- variant="outline"
- onClick={add}
- data-testid={`alias-add-${testid}`}
- >
- + Add alias
- </Button>
- </div>
- </section>
- );
-}
-
-function AliasRow({
- row,
- onPatch,
- onRemove,
- testid,
-}: {
- row: Row;
- onPatch: (next: Partial<Row>) => void;
- onRemove: () => void;
- testid: string;
-}) {
- const triggers = parseTriggers(row.triggersText);
- const regexInvalid =
- row.useRegex && row.suggestion.trim() !== "" && !isValidRegex(row.suggestion);
-
- return (
- <li
- className={cnRow(row.enabled)}
- data-testid={`alias-row-${testid}`}
- >
- <div className="flex flex-wrap items-center gap-3">
- <label className="flex flex-1 min-w-[10rem] flex-col gap-1 text-xs text-muted-foreground">
- Concept
- <Input
- value={row.label}
- onChange={(e) => onPatch({ label: e.target.value })}
- placeholder="e.g. loli"
- data-testid={`alias-label-${testid}`}
- />
- </label>
- <label className="flex select-none items-center gap-1.5 text-xs">
- <Checkbox
- checked={row.enabled}
- onCheckedChange={(c) => onPatch({ enabled: c === true })}
- data-testid={`alias-enabled-${testid}`}
- />
- Enabled
- </label>
- <Button
- type="button"
- variant="outline"
- size="icon-xs"
- onClick={onRemove}
- aria-label={`Remove ${row.label || "alias"}`}
- data-testid={`alias-remove-${testid}`}
- >
- ×
- </Button>
- </div>
-
- <label className="flex flex-col gap-1 text-xs text-muted-foreground">
- Triggers <span className="text-[11px]">(comma-separated spellings)</span>
- <Input
- value={row.triggersText}
- onChange={(e) => onPatch({ triggersText: e.target.value })}
- placeholder="loli, lolly, loly"
- data-testid={`alias-triggers-${testid}`}
- />
- </label>
- {triggers.length > 0 && (
- <div className="flex flex-wrap gap-1">
- {triggers.map((t, i) => (
- <span
- key={`${t}-${i}`}
- className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
- >
- {t}
- </span>
- ))}
- </div>
- )}
-
- <div className="flex flex-wrap items-end gap-3">
- <label className="flex flex-1 min-w-[12rem] flex-col gap-1 text-xs text-muted-foreground">
- Suggested query
- <Input
- value={row.suggestion}
- onChange={(e) => onPatch({ suggestion: e.target.value })}
- placeholder="\blol(i|ly)"
- aria-invalid={regexInvalid || undefined}
- className={row.useRegex ? "font-mono" : undefined}
- data-testid={`alias-suggestion-${testid}`}
- />
- </label>
- <label className="flex select-none items-center gap-1.5 pb-2 text-xs">
- <Checkbox
- checked={row.useRegex}
- onCheckedChange={(c) => onPatch({ useRegex: c === true })}
- data-testid={`alias-regex-${testid}`}
- />
- Regex
- </label>
- </div>
- {regexInvalid && (
- <p className="font-mono text-xs text-destructive">Invalid regex</p>
- )}
-
- <label className="flex flex-col gap-1 text-xs text-muted-foreground">
- Note <span className="text-[11px]">(optional)</span>
- <Input
- value={row.note}
- onChange={(e) => onPatch({ note: e.target.value })}
- placeholder="Why this alias exists"
- data-testid={`alias-note-${testid}`}
- />
- </label>
-
- {triggers[0] && row.suggestion && (
- <p className="text-[11px] text-muted-foreground">
- Preview: typing <code className="font-mono">{triggers[0]}</code> suggests{" "}
- <code className="font-mono">{row.suggestion}</code>
- </p>
- )}
- </li>
- );
-}
-
-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(" ");
-}
diff --git a/editor/app/aliases/page.tsx b/editor/app/aliases/page.tsx
@@ -1,62 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- readGlobalAliases,
- readSiteAliases,
-} from "yt-dlp-transcript-common/lib/aliasesStore";
-import { listSiteIds } from "yt-dlp-transcript-common/lib/site";
-import { EditorAliasesClient } from "./EditorAliasesClient";
-import { resolveActiveSite } from "../lib/activeSite";
-
-export const dynamic = "force-dynamic";
-export const metadata: Metadata = { title: "Search aliases" };
-
-export default async function AliasesPage({
- searchParams,
-}: {
- searchParams: Promise<{ site?: string }>;
-}) {
- const paths = getPaths();
- const { site } = await searchParams;
- const siteIds = listSiteIds(paths);
- const active = resolveActiveSite(site, siteIds);
- // Per-site overrides need a specific site; global aliases are editable always.
- const siteId = active.isAll ? null : active.siteId ?? null;
-
- const globalConfig = readGlobalAliases(paths);
- const siteConfig = siteId ? readSiteAliases(paths, siteId) : null;
-
- return (
- <div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Search aliases</h1>
- <p className="max-w-2xl text-sm text-muted-foreground">
- When a searcher types a known term, the viewer offers a better regex they
- can apply — never forced. A concept groups the spellings that trigger it
- (transcription often mangles them) with one replacement pattern. Global
- aliases apply everywhere;{" "}
- {siteId ? (
- <>
- per-site aliases below add to or override them for{" "}
- <strong>{siteId}</strong>.
- </>
- ) : (
- <>
- pick a site in the sidebar to add per-site overrides. Changes bake
- into a site's next{" "}
- <Link className="underline" href="/build">
- export build
- </Link>
- .
- </>
- )}
- </p>
- <EditorAliasesClient
- key={siteId ?? "__global__"}
- globalConfig={globalConfig}
- siteConfig={siteConfig}
- siteId={siteId}
- />
- </div>
- );
-}
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -61,7 +61,8 @@ export async function buildStatsAction(
paths,
fn: async (onLog, signal) => {
await buildStats({ paths, onLog, signal });
- revalidatePath("/charts");
+ // every site's Charts tab previews the dataset this just rebuilt.
+ revalidatePath("/sites/[siteId]/charts", "page");
},
});
}
diff --git a/editor/app/charts/EditorChartsClient.tsx b/editor/app/charts/EditorChartsClient.tsx
@@ -1,78 +0,0 @@
-"use client";
-
-import { useCallback } from "react";
-import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
-import { useStats } from "yt-dlp-transcript-common/components/statsCache";
-import { useSummaries } from "yt-dlp-transcript-common/components/summariesCache";
-import { ChartsDashboard } from "yt-dlp-transcript-common/components/charts/ChartsDashboard";
-import {
- PRESET_GROUPS,
- type ChartTemplates,
- type Dashboard,
-} from "yt-dlp-transcript-common/lib/chartConfig";
-import { saveDefaultDashboardAction } from "./actions";
-
-function Inner({
- templates,
- siteId,
-}: {
- templates: ChartTemplates;
- siteId: string;
-}) {
- const { stats, ready, loadedPages, pageCount, manifest, error } = useStats();
- const { summaries } = useSummaries();
-
- const onPersist = useCallback(
- (dashboard: Dashboard) => {
- void saveDefaultDashboardAction(siteId, dashboard);
- },
- [siteId],
- );
-
- if (error) {
- return (
- <p className="text-sm text-destructive">
- Failed to load the stats dataset ({error.message}). Run{" "}
- <strong>Build index</strong> then <strong>Build stats dataset</strong>{" "}
- on the Build page first.
- </p>
- );
- }
- if (!ready) {
- return (
- <p className="text-sm text-muted-foreground">
- Loading stats… {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""}
- </p>
- );
- }
-
- const channels = (manifest?.channels ?? []).map((c) => ({
- slug: c.slug,
- name: c.name,
- }));
-
- return (
- <ChartsDashboard
- initialDashboard={templates.defaultDashboard}
- gallery={PRESET_GROUPS}
- stats={stats}
- summaries={summaries}
- channels={channels}
- onPersist={onPersist}
- />
- );
-}
-
-export function EditorChartsClient({
- templates,
- siteId,
-}: {
- templates: ChartTemplates;
- siteId: string;
-}) {
- return (
- <QueryProvider>
- <Inner templates={templates} siteId={siteId} />
- </QueryProvider>
- );
-}
diff --git a/editor/app/charts/page.tsx b/editor/app/charts/page.tsx
@@ -1,71 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { readTemplates } from "yt-dlp-transcript-common/lib/chartsStore";
-import { listSiteIds } from "yt-dlp-transcript-common/lib/site";
-import { EditorChartsClient } from "./EditorChartsClient";
-import { resolveActiveSite } from "../lib/activeSite";
-
-export const dynamic = "force-dynamic";
-export const metadata: Metadata = { title: "Charts" };
-
-export default async function ChartsPage({
- searchParams,
-}: {
- searchParams: Promise<{ site?: string }>;
-}) {
- const paths = getPaths();
- const { site } = await searchParams;
- const siteIds = listSiteIds(paths);
-
- if (siteIds.length === 0) {
- return (
- <div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Charts</h1>
- <p className="text-sm text-muted-foreground">
- No sites configured. Create one on the{" "}
- <Link className="underline" href="/sites">
- Sites
- </Link>{" "}
- page first.
- </p>
- </div>
- );
- }
-
- const active = resolveActiveSite(site, siteIds);
- // Charts authors one site's default dashboard, so a single site is required.
- if (active.isAll || !active.siteId) {
- return (
- <div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Charts</h1>
- <p className="text-sm text-muted-foreground">
- Select a specific site from the site selector in the sidebar to edit
- its charts.
- </p>
- </div>
- );
- }
- const siteId = active.siteId;
- const templates = readTemplates(paths, siteId);
-
- return (
- <div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Charts</h1>
- <p className="mt-1 text-sm text-muted-foreground">
- Author the default dashboard for <strong>{siteId}</strong>. Edits save
- automatically and bake into that site's next export build. Requires
- the stats dataset — build it on the{" "}
- <Link className="underline" href="/build">
- Build
- </Link>{" "}
- page.
- </p>
- <EditorChartsClient
- key={siteId}
- templates={templates}
- siteId={siteId}
- />
- </div>
- );
-}
diff --git a/editor/app/components/SiteScopeSelect.tsx b/editor/app/components/SiteScopeSelect.tsx
@@ -6,27 +6,33 @@ import {
ACTIVE_SITE_KEY,
ALL_SITES,
resolveActiveSite,
+ siteIdFromPathname,
} from "../lib/activeSite";
export type SiteScopeOption = { siteId: string; siteTitle: string };
// The single global site selector shown in the sidebar. Persists the choice in
// localStorage (the source of truth) and mirrors it into the `?site=` URL param
-// so the server-rendered scoped pages (Dashboard, Channels, Charts, Deploy) can
-// read it from their `searchParams`. See app/lib/activeSite.ts.
+// so the server-rendered scoped pages (Dashboard, Channels) can read it from
+// their `searchParams`. On a site's own pages (`/sites/<id>/…`) the path IS the
+// selection: the picker shows it, writes it to storage so Dashboard and Channels
+// follow, and changing it navigates to the same tab of the other site. See
+// app/lib/activeSite.ts.
export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const siteIds = sites.map((s) => s.siteId);
const urlValue = searchParams.get("site");
- const resolved = resolveActiveSite(urlValue, siteIds);
+ const onSite = siteIdFromPathname(pathname);
+ const resolved = resolveActiveSite(onSite?.siteId ?? urlValue, siteIds);
const multi = sites.length > 1;
// The /sites CRUD pages manage every site and never read ?site=; seeding it
// there would only let the mount-effect replace() below clobber an in-flight
// push to /sites/<id> (a link click on the list), bouncing the user back to
- // /sites?site=<id>. Only the scoped server pages (Dashboard, Channels, Charts,
- // Deploy) consume the param, so restrict the seed to non-/sites routes.
+ // /sites?site=<id>. Only the scoped server pages (Dashboard, Channels) consume
+ // the param, so restrict the seed to non-/sites routes. On `/sites/<id>/…` the
+ // picker reads the path instead (below) and never seeds either.
const seedsSiteParam = !pathname.startsWith("/sites");
function setParam(value: string) {
@@ -40,11 +46,14 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) {
// preference so server components pick up the active site on navigation.
useEffect(() => {
if (siteIds.length === 0) return;
- const urlIsValid =
- urlValue === ALL_SITES || (!!urlValue && siteIds.includes(urlValue));
- if (urlIsValid) {
+ // The path wins over the param: on a site's own pages it IS the selection,
+ // and writing it to storage is what makes Dashboard and Channels follow.
+ const chosen = onSite?.siteId ?? urlValue;
+ const chosenIsValid =
+ chosen === ALL_SITES || (!!chosen && siteIds.includes(chosen));
+ if (chosenIsValid) {
try {
- window.localStorage.setItem(ACTIVE_SITE_KEY, urlValue as string);
+ window.localStorage.setItem(ACTIVE_SITE_KEY, chosen as string);
} catch {
/* ignore */
}
@@ -67,6 +76,16 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) {
} catch {
/* ignore */
}
+ if (onSite) {
+ // The site is the path here, so changing it is a navigation, not a param:
+ // the same tab of the other site, or the family page for "All sites".
+ router.push(
+ value === ALL_SITES
+ ? "/sites"
+ : `/sites/${value}${onSite.segment ? `/${onSite.segment}` : ""}`,
+ );
+ return;
+ }
setParam(value);
}
diff --git a/editor/app/lib/activeSite.test.ts b/editor/app/lib/activeSite.test.ts
@@ -0,0 +1,39 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { siteIdFromPathname } from "./activeSite";
+
+// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts"
+//
+// The picker and the site's own pages must agree on which site is selected, and
+// under /sites/<id>/<tab> the PATH is the answer. This is the whole rule, pure:
+// no router, no storage, no disk.
+
+test("a site's own page yields the id and no segment", () => {
+ assert.deepEqual(siteIdFromPathname("/sites/alpha"), {
+ siteId: "alpha",
+ segment: null,
+ });
+});
+
+test("a tab yields the id and the segment", () => {
+ assert.deepEqual(siteIdFromPathname("/sites/alpha/charts"), {
+ siteId: "alpha",
+ segment: "charts",
+ });
+});
+
+test("the family page names no site", () => {
+ assert.equal(siteIdFromPathname("/sites"), null);
+});
+
+test("/sites/new is a static route, not a site", () => {
+ assert.equal(siteIdFromPathname("/sites/new"), null);
+});
+
+test("routes outside /sites name no site", () => {
+ assert.equal(siteIdFromPathname("/channels"), null);
+});
+
+test("a deeper path than a tab names no site", () => {
+ assert.equal(siteIdFromPathname("/sites/a/b/c"), null);
+});
diff --git a/editor/app/lib/activeSite.ts b/editor/app/lib/activeSite.ts
@@ -1,9 +1,13 @@
// Shared notion of "the site I'm working on" for the editor's site-scoped views
-// (Dashboard, Channels, Charts, Deploy). The selection is persisted client-side
-// in localStorage by SiteScopeSelect, but those pages are server components, so
-// the selection is mirrored into the URL `?site=` query param and read here from
-// the page's `searchParams`. This module has NO server-only imports so it can be
-// shared by the client selector too.
+// (Dashboard, Channels). The selection is persisted client-side in localStorage
+// by SiteScopeSelect, but those pages are server components, so the selection is
+// mirrored into the URL `?site=` query param and read here from the page's
+// `searchParams`. This module has NO server-only imports so it can be shared by
+// the client selector too.
+//
+// ON A SITE'S OWN PAGES the site is the PATH, not the param: /sites/<id>/<tab>
+// names it, the tab reads `params.siteId`, and the picker reads the same path
+// through `siteIdFromPathname` so the two never disagree.
// Sentinel param value meaning "all sites" (full channel pool). A bare string so
// it can never collide with a real siteId (which matches SITE_ID_RE).
@@ -49,3 +53,19 @@ export function resolveActiveSite(
}
return all;
}
+
+// The routes under /sites where the path names the site. `new` is /sites/new,
+// a static route that beats [siteId] and is not a site. The id pattern is
+// SITE_ID_RE re-spelled: common/lib/site.ts imports node:fs and this module
+// must stay importable from the client picker.
+const SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/;
+
+export type SitePath = { siteId: string; segment: string | null };
+
+// "/sites/alpha" → { alpha, null }; "/sites/alpha/charts" → { alpha, "charts" };
+// "/sites", "/sites/new", "/channels", "/sites/a/b/c" → null.
+export function siteIdFromPathname(pathname: string): SitePath | null {
+ const m = SITE_PATH_RE.exec(pathname);
+ if (!m || m[1] === "new") return null;
+ return { siteId: m[1], segment: m[2] ?? null };
+}
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -1,6 +1,5 @@
import type { LucideIcon } from "lucide-react";
import {
- ChartColumnBig,
ClipboardCheck,
Cpu,
Globe,
@@ -9,7 +8,6 @@ import {
LayoutDashboard,
ListChecks,
ListPlus,
- Regex,
Rocket,
ScrollText,
Settings,
@@ -85,11 +83,9 @@ export const NAV_GROUPS: NavGroup[] = [
{
label: "Sites",
links: [
- { href: "/sites", label: "Sites", icon: Globe },
- // Interim: slice 5 folds these four into /sites/[siteId]. They are all
- // per-site facts already; only their routes say otherwise.
- { href: "/charts", label: "Charts", icon: ChartColumnBig, keywords: "stats graphs" },
- { href: "/aliases", label: "Search aliases", icon: Regex, keywords: "synonyms suggestions regex search terms" },
+ { href: "/sites", label: "Sites", icon: Globe, keywords: "family charts stats graphs search aliases synonyms suggestions regex search terms" },
+ // Interim: slice 5 folds these three into /sites — Deploy into the site's
+ // Publish tab and the family page, Build and Homepage into the family page.
{ href: "/deploy", label: "Deploy", icon: Rocket, keywords: "publish release" },
{ href: "/build", label: "Build", icon: Hammer, keywords: "static export" },
{ href: "/homepage", label: "Homepage", icon: House, keywords: "hub" },
diff --git a/editor/app/sites/[siteId]/aliases/page.tsx b/editor/app/sites/[siteId]/aliases/page.tsx
@@ -0,0 +1,47 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ readGlobalAliases,
+ readSiteAliases,
+} from "yt-dlp-transcript-common/lib/aliasesStore";
+import { EditorAliasesClient } from "../../components/EditorAliasesClient";
+import { getSiteCached } from "../lib/siteCache";
+
+export const dynamic = "force-dynamic";
+// → "Search aliases — <id> — Sites" through the layout's title template.
+export const metadata: Metadata = { title: "Search aliases" };
+
+// Both sections live here: the global dictionary AND this site's overrides. A
+// per-site alias shadows the global one with the same id at compose time
+// (aliasesStore.ts), so a tab that showed only the overlay would hide the thing
+// it overlays.
+export default async function SiteAliasesPage({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ if (!getSiteCached(siteId)) notFound();
+ const paths = getPaths();
+ const globalConfig = readGlobalAliases(paths);
+ const siteConfig = readSiteAliases(paths, siteId);
+ return (
+ <section className="flex flex-col gap-4">
+ <p className="max-w-2xl text-sm text-muted-foreground">
+ When a searcher types a known term, the viewer offers a better regex they
+ can apply — never forced. A concept groups the spellings that trigger it
+ (transcription often mangles them) with one replacement pattern. Global
+ aliases apply everywhere; per-site aliases below add to or override them
+ for <strong>{siteId}</strong>. Changes bake into the site's next
+ export build.
+ </p>
+ <EditorAliasesClient
+ key={siteId}
+ globalConfig={globalConfig}
+ siteConfig={siteConfig}
+ siteId={siteId}
+ />
+ </section>
+ );
+}
diff --git a/editor/app/sites/[siteId]/charts/page.tsx b/editor/app/sites/[siteId]/charts/page.tsx
@@ -0,0 +1,36 @@
+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 { readTemplates } from "yt-dlp-transcript-common/lib/chartsStore";
+import { EditorChartsClient } from "../../components/EditorChartsClient";
+import { getSiteCached } from "../lib/siteCache";
+
+export const dynamic = "force-dynamic";
+// → "Charts — <id> — Sites" through the layout's title template.
+export const metadata: Metadata = { title: "Charts" };
+
+export default async function SiteChartsPage({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ if (!getSiteCached(siteId)) notFound();
+ const templates = readTemplates(getPaths(), siteId);
+ return (
+ <section className="flex flex-col gap-4">
+ <p className="text-sm text-muted-foreground">
+ Author the default dashboard for <strong>{siteId}</strong>. Edits save
+ automatically and bake into this site's next export build. Requires
+ the stats dataset — build it on the{" "}
+ <Link className="underline" href="/build">
+ Build
+ </Link>{" "}
+ page. The preview draws the stats dataset as last composed, which may be
+ another site's.
+ </p>
+ <EditorChartsClient key={siteId} templates={templates} siteId={siteId} />
+ </section>
+ );
+}
diff --git a/editor/app/sites/[siteId]/components/SiteTabs.tsx b/editor/app/sites/[siteId]/components/SiteTabs.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import Link from "next/link";
+import { useSelectedLayoutSegment } from "next/navigation";
+
+// Settings | Charts | Search aliases | 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
+// an active-tab check on a client-side move between two tabs.
+// useSelectedLayoutSegment is the supported way to ask "which child segment is
+// rendered"; it is null on the index page, which is the Settings tab.
+
+export function SiteTabs({ siteId }: { siteId: string }) {
+ const segment = useSelectedLayoutSegment();
+ return (
+ <nav aria-label="site sections" className="flex items-center gap-1">
+ <Tab href={`/sites/${siteId}`} active={segment === null} label="Settings" />
+ <Tab
+ href={`/sites/${siteId}/charts`}
+ active={segment === "charts"}
+ label="Charts"
+ />
+ <Tab
+ href={`/sites/${siteId}/aliases`}
+ active={segment === "aliases"}
+ label="Search aliases"
+ />
+ {/* commit 2 adds: <Tab href={`/sites/${siteId}/publish`} active={segment === "publish"} label="Publish" /> */}
+ </nav>
+ );
+}
+
+function Tab({
+ href,
+ active,
+ label,
+}: {
+ href: string;
+ active: boolean;
+ label: string;
+}) {
+ return (
+ <Link
+ href={href}
+ aria-current={active ? "page" : undefined}
+ aria-label={`${label} tab`}
+ className={`rounded-md px-3 py-1 text-sm ${
+ active
+ ? "bg-muted text-foreground font-medium"
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
+ }`}
+ >
+ {label}
+ </Link>
+ );
+}
diff --git a/editor/app/sites/[siteId]/layout.tsx b/editor/app/sites/[siteId]/layout.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { SiteTabs } from "./components/SiteTabs";
+import { getSiteCached } from "./lib/siteCache";
+
+// Shared chrome for every route of one site: the breadcrumb, the site's title
+// and the Settings | Charts | Search aliases | Publish switch. A layout now that
+// the site has more than one page — Charts, Search aliases and Publish were
+// three sidebar entries reading the site from a ?site= param.
+//
+// Next 16 caveats this file is written around (see
+// node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/layout.md):
+//
+// - LAYOUTS DO NOT RERENDER on navigation and cannot read searchParams or the
+// pathname. So the active-tab check lives in a client component (SiteTabs).
+// - NO loading.tsx HERE. In this app it turns notFound() 404s into 200s and
+// never shows on a client-side sibling navigation anyway. The notFound()
+// below is the one 404 for /sites/<missing> and every tab under it.
+//
+// force-dynamic for the same reason page.tsx carries it: this reads site.json
+// off disk on every request and must never be statically prerendered.
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}): Promise<Metadata> {
+ const { siteId } = await params;
+ // The index page (the settings form) exports no title and gets `default`;
+ // each tab exports a bare title and gets the template
+ // (generate-metadata.md: a layout's template applies to CHILD segments).
+ return {
+ title: { default: `${siteId} — Sites`, template: `%s — ${siteId} — Sites` },
+ };
+}
+
+export default async function SiteLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ // Deduped with each tab page's read of the same file via React cache().
+ const site = getSiteCached(siteId);
+ if (!site) notFound();
+
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Link href="/sites" className="underline hover:text-foreground">
+ Sites
+ </Link>
+ <span>/</span>
+ <span>{siteId}</span>
+ </div>
+ <div className="flex items-center justify-between gap-2 flex-wrap">
+ <h1 className="text-2xl font-semibold">{site.siteTitle || siteId}</h1>
+ <SiteTabs siteId={siteId} />
+ </div>
+ {children}
+ </div>
+ );
+}
diff --git a/editor/app/sites/[siteId]/lib/siteCache.ts b/editor/app/sites/[siteId]/lib/siteCache.ts
@@ -0,0 +1,21 @@
+import { cache } from "react";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getSite,
+ isValidSiteId,
+ listSiteIds,
+ type Site,
+} from "yt-dlp-transcript-common/lib/site";
+
+// The layout and every tab page need the site — the layout for the title,
+// the page for its own section. React cache() dedupes them into ONE read per
+// request (the channel routes do the same, channelConfigCache.ts). Returns
+// null for an id that is malformed or has no site.json, so the caller's
+// notFound() is the one 404 for /sites/<missing> and every tab under it.
+// Request-scoped, not a process cache: a site renamed by the form on the
+// previous request is read fresh on the next one.
+export const getSiteCached = cache((siteId: string): Site | null => {
+ const paths = getPaths();
+ if (!isValidSiteId(siteId) || !listSiteIds(paths).includes(siteId)) return null;
+ return getSite(siteId, paths);
+});
diff --git a/editor/app/sites/[siteId]/page.tsx b/editor/app/sites/[siteId]/page.tsx
@@ -1,31 +1,16 @@
-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 { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
-import {
- getSite,
- isValidSiteId,
- listSiteIds,
- listSites,
-} from "yt-dlp-transcript-common/lib/site";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
import {
SiteForm,
type ChannelOption,
type SiteOption,
} from "../components/SiteForm";
+import { getSiteCached } from "./lib/siteCache";
export const dynamic = "force-dynamic";
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ siteId: string }>;
-}): Promise<Metadata> {
- const { siteId } = await params;
- return { title: `${siteId} — Sites` };
-}
-
export default async function EditSitePage({
params,
}: {
@@ -33,10 +18,8 @@ export default async function EditSitePage({
}) {
const { siteId } = await params;
const paths = getPaths();
- if (!isValidSiteId(siteId) || !listSiteIds(paths).includes(siteId)) {
- notFound();
- }
- const site = getSite(siteId, paths);
+ const site = getSiteCached(siteId);
+ if (!site) notFound();
const channels: ChannelOption[] = (await listChannelConfigs(paths)).map((c) => ({
slug: c.slug,
name: c.config.name ?? c.slug,
@@ -46,22 +29,6 @@ export default async function EditSitePage({
.map((s) => ({ siteId: s.siteId, siteTitle: s.siteTitle }));
return (
<div className="flex flex-col gap-4">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Link href="/sites" className="underline hover:text-foreground">
- Sites
- </Link>
- <span>/</span>
- <span>{siteId}</span>
- </div>
- <div className="flex items-center justify-between gap-2">
- <h1 className="text-2xl font-semibold">{site.siteTitle || siteId}</h1>
- <Link
- href={`/charts?site=${siteId}`}
- className="text-sm underline hover:text-foreground"
- >
- Edit charts dashboard →
- </Link>
- </div>
<SiteForm
initial={site}
channels={channels}
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -190,7 +190,9 @@ export async function saveSiteAction(
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
revalidatePath("/sites");
- revalidatePath(`/sites/${siteId}`);
+ // "layout": the h1 above every tab reads siteTitle, and a literal path would
+ // revalidate only the settings page (revalidatePath.md).
+ revalidatePath("/sites/[siteId]", "layout");
return { ok: true, siteId };
}
diff --git a/editor/app/sites/components/EditorAliasesClient.tsx b/editor/app/sites/components/EditorAliasesClient.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+// Authoring UI for search aliases. Two independent sections — the global
+// dictionary and (when a site is selected) that 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 | null;
+ siteId: string | null;
+}) {
+ return (
+ <div className="flex flex-col gap-8">
+ <AliasSection
+ title="Global"
+ subtitle="Suggested on every site."
+ initial={globalConfig.aliases}
+ onSave={(cfg) => saveGlobalAliasesAction(cfg)}
+ testid="global"
+ />
+ {siteId && siteConfig && (
+ <AliasSection
+ title={`This site — ${siteId}`}
+ subtitle="Adds to the global list. An entry that reuses a global id overrides it (set it disabled to hide that global alias here)."
+ initial={siteConfig.aliases}
+ onSave={(cfg) => saveSiteAliasesAction(siteId, cfg)}
+ testid="site"
+ />
+ )}
+ </div>
+ );
+}
+
+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<Row[]>(() =>
+ 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<Row>) => {
+ 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 (
+ <section
+ className="flex flex-col gap-3"
+ data-testid={`alias-section-${testid}`}
+ >
+ <div className="flex items-baseline justify-between gap-4">
+ <div>
+ <h2 className="text-lg font-semibold">{title}</h2>
+ <p className="text-xs text-muted-foreground">{subtitle}</p>
+ </div>
+ <div className="flex items-center gap-2">
+ {saved && !dirty && (
+ <span className="text-xs text-success" data-testid={`alias-saved-${testid}`}>
+ Saved
+ </span>
+ )}
+ <Button
+ type="button"
+ size="sm"
+ onClick={save}
+ disabled={!dirty || saving || anyRegexInvalid}
+ data-testid={`alias-save-${testid}`}
+ >
+ {saving ? "Saving…" : "Save"}
+ </Button>
+ </div>
+ </div>
+
+ {rows.length === 0 ? (
+ <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
+ No aliases yet. Add one to start suggesting better queries.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3">
+ {rows.map((r) => (
+ <AliasRow
+ key={r.key}
+ row={r}
+ onPatch={(next) => patch(r.key, next)}
+ onRemove={() => remove(r.key)}
+ testid={testid}
+ />
+ ))}
+ </ul>
+ )}
+
+ <div>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ onClick={add}
+ data-testid={`alias-add-${testid}`}
+ >
+ + Add alias
+ </Button>
+ </div>
+ </section>
+ );
+}
+
+function AliasRow({
+ row,
+ onPatch,
+ onRemove,
+ testid,
+}: {
+ row: Row;
+ onPatch: (next: Partial<Row>) => void;
+ onRemove: () => void;
+ testid: string;
+}) {
+ const triggers = parseTriggers(row.triggersText);
+ const regexInvalid =
+ row.useRegex && row.suggestion.trim() !== "" && !isValidRegex(row.suggestion);
+
+ return (
+ <li
+ className={cnRow(row.enabled)}
+ data-testid={`alias-row-${testid}`}
+ >
+ <div className="flex flex-wrap items-center gap-3">
+ <label className="flex flex-1 min-w-[10rem] flex-col gap-1 text-xs text-muted-foreground">
+ Concept
+ <Input
+ value={row.label}
+ onChange={(e) => onPatch({ label: e.target.value })}
+ placeholder="e.g. loli"
+ data-testid={`alias-label-${testid}`}
+ />
+ </label>
+ <label className="flex select-none items-center gap-1.5 text-xs">
+ <Checkbox
+ checked={row.enabled}
+ onCheckedChange={(c) => onPatch({ enabled: c === true })}
+ data-testid={`alias-enabled-${testid}`}
+ />
+ Enabled
+ </label>
+ <Button
+ type="button"
+ variant="outline"
+ size="icon-xs"
+ onClick={onRemove}
+ aria-label={`Remove ${row.label || "alias"}`}
+ data-testid={`alias-remove-${testid}`}
+ >
+ ×
+ </Button>
+ </div>
+
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Triggers <span className="text-[11px]">(comma-separated spellings)</span>
+ <Input
+ value={row.triggersText}
+ onChange={(e) => onPatch({ triggersText: e.target.value })}
+ placeholder="loli, lolly, loly"
+ data-testid={`alias-triggers-${testid}`}
+ />
+ </label>
+ {triggers.length > 0 && (
+ <div className="flex flex-wrap gap-1">
+ {triggers.map((t, i) => (
+ <span
+ key={`${t}-${i}`}
+ className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
+ >
+ {t}
+ </span>
+ ))}
+ </div>
+ )}
+
+ <div className="flex flex-wrap items-end gap-3">
+ <label className="flex flex-1 min-w-[12rem] flex-col gap-1 text-xs text-muted-foreground">
+ Suggested query
+ <Input
+ value={row.suggestion}
+ onChange={(e) => onPatch({ suggestion: e.target.value })}
+ placeholder="\blol(i|ly)"
+ aria-invalid={regexInvalid || undefined}
+ className={row.useRegex ? "font-mono" : undefined}
+ data-testid={`alias-suggestion-${testid}`}
+ />
+ </label>
+ <label className="flex select-none items-center gap-1.5 pb-2 text-xs">
+ <Checkbox
+ checked={row.useRegex}
+ onCheckedChange={(c) => onPatch({ useRegex: c === true })}
+ data-testid={`alias-regex-${testid}`}
+ />
+ Regex
+ </label>
+ </div>
+ {regexInvalid && (
+ <p className="font-mono text-xs text-destructive">Invalid regex</p>
+ )}
+
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Note <span className="text-[11px]">(optional)</span>
+ <Input
+ value={row.note}
+ onChange={(e) => onPatch({ note: e.target.value })}
+ placeholder="Why this alias exists"
+ data-testid={`alias-note-${testid}`}
+ />
+ </label>
+
+ {triggers[0] && row.suggestion && (
+ <p className="text-[11px] text-muted-foreground">
+ Preview: typing <code className="font-mono">{triggers[0]}</code> suggests{" "}
+ <code className="font-mono">{row.suggestion}</code>
+ </p>
+ )}
+ </li>
+ );
+}
+
+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(" ");
+}
diff --git a/editor/app/sites/components/EditorChartsClient.tsx b/editor/app/sites/components/EditorChartsClient.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useCallback } from "react";
+import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
+import { useStats } from "yt-dlp-transcript-common/components/statsCache";
+import { useSummaries } from "yt-dlp-transcript-common/components/summariesCache";
+import { ChartsDashboard } from "yt-dlp-transcript-common/components/charts/ChartsDashboard";
+import {
+ PRESET_GROUPS,
+ type ChartTemplates,
+ type Dashboard,
+} from "yt-dlp-transcript-common/lib/chartConfig";
+import { saveDefaultDashboardAction } from "../lib/chartsActions";
+
+function Inner({
+ templates,
+ siteId,
+}: {
+ templates: ChartTemplates;
+ siteId: string;
+}) {
+ const { stats, ready, loadedPages, pageCount, manifest, error } = useStats();
+ const { summaries } = useSummaries();
+
+ const onPersist = useCallback(
+ (dashboard: Dashboard) => {
+ void saveDefaultDashboardAction(siteId, dashboard);
+ },
+ [siteId],
+ );
+
+ if (error) {
+ return (
+ <p className="text-sm text-destructive">
+ Failed to load the stats dataset ({error.message}). Run{" "}
+ <strong>Build index</strong> then <strong>Build stats dataset</strong>{" "}
+ on the Build page first.
+ </p>
+ );
+ }
+ if (!ready) {
+ return (
+ <p className="text-sm text-muted-foreground">
+ Loading stats… {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""}
+ </p>
+ );
+ }
+
+ const channels = (manifest?.channels ?? []).map((c) => ({
+ slug: c.slug,
+ name: c.name,
+ }));
+
+ return (
+ <ChartsDashboard
+ initialDashboard={templates.defaultDashboard}
+ gallery={PRESET_GROUPS}
+ stats={stats}
+ summaries={summaries}
+ channels={channels}
+ onPersist={onPersist}
+ />
+ );
+}
+
+export function EditorChartsClient({
+ templates,
+ siteId,
+}: {
+ templates: ChartTemplates;
+ siteId: string;
+}) {
+ return (
+ <QueryProvider>
+ <Inner templates={templates} siteId={siteId} />
+ </QueryProvider>
+ );
+}
diff --git a/editor/app/aliases/actions.ts b/editor/app/sites/lib/aliasesActions.ts
diff --git a/editor/app/charts/actions.ts b/editor/app/sites/lib/chartsActions.ts
diff --git a/editor/e2e/aliases.spec.ts b/editor/e2e/aliases.spec.ts
@@ -1,8 +1,10 @@
import { expect, test } from "@playwright/test";
import { resetData, writeSite, readJson } from "./helpers";
-// Search-aliases authoring page. Global aliases seed with defaults; per-site
-// overrides round-trip to sites/<id>/search-aliases.json via a server action.
+// Search-aliases authoring on the site's Search aliases tab: the global
+// dictionary and the site's overrides, both. Global aliases seed with defaults;
+// per-site overrides round-trip to sites/<id>/search-aliases.json via a server
+// action.
type AliasFile = { aliases: { id: string; triggers: string[] }[] };
@@ -13,7 +15,7 @@ test.describe("search aliases authoring", () => {
});
test("Global section pre-fills the seeded defaults", async ({ page }) => {
- await page.goto("/aliases");
+ await page.goto("/sites/testsite/aliases");
await expect(page.getByTestId("alias-section-global")).toBeVisible();
// The canonical seeded default is the first global row.
await expect(
@@ -22,7 +24,7 @@ test.describe("search aliases authoring", () => {
});
test("adding a per-site alias round-trips to disk", async ({ page }) => {
- await page.goto("/aliases?site=testsite");
+ await page.goto("/sites/testsite/aliases");
await expect(page.getByTestId("alias-section-site")).toBeVisible();
await page.getByTestId("alias-add-site").click();
@@ -49,7 +51,7 @@ test.describe("search aliases authoring", () => {
});
test("editing then saving the Global list persists it", async ({ page }) => {
- await page.goto("/aliases");
+ await page.goto("/sites/testsite/aliases");
// Change the first default's suggestion, then save.
const firstSuggestion = page
.locator('[data-testid="alias-suggestion-global"]')
diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
-import { resetData } from "./helpers";
+import { resetData, writeSite } from "./helpers";
// Navigation contract for the editor after the corpus walk was removed from
// every render path.
@@ -107,6 +107,7 @@ test.describe("navigation", () => {
// needs doing" on one page; the board is where a bookmark lands now.
// /scheduler was the sync operation's page before sync was catalogued.
// /jobs/active and /jobs/queue were two thirds of /jobs.
+ // The Sites group's five satellites fold into /sites and the site's tabs.
test("a retired route redirects, never 404s", async ({ page }) => {
await page.goto("/actionable");
await expect(page).toHaveURL(/\/operations$/);
@@ -127,6 +128,22 @@ test.describe("navigation", () => {
page.getByRole("heading", { name: "Jobs", level: 1 }),
).toBeVisible();
}
+
+ await writeSite("testsite", {});
+ for (const retired of ["/charts", "/aliases"]) {
+ await page.goto(retired);
+ await expect(page).toHaveURL(/\/sites$/);
+ await expect(
+ page.getByRole("heading", { name: "Sites", level: 1 }),
+ ).toBeVisible();
+ }
+ // A ?site= bookmark is carried to the site's tab; the query survives the
+ // redirect.
+ await page.goto("/charts?site=testsite");
+ await expect(page).toHaveURL(/\/sites\/testsite\/charts(\?|$)/);
+ await expect(
+ page.getByRole("link", { name: "Charts tab" }),
+ ).toHaveAttribute("aria-current", "page");
});
test("a missing resource returns 404, not 200", async ({ page }) => {
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -58,13 +58,26 @@ test("dashboard stats and table scope to the active site", async ({ page }) => {
await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
});
-test("charts requires a single site", async ({ page }) => {
+test("charts is a site's tab, and the picker follows the path", async ({
+ page,
+}) => {
await twoSites();
- await page.goto("/charts?site=__all__");
- await expect(page.getByText(/select a specific site/i)).toBeVisible();
-
- await page.goto("/charts?site=alpha");
+ await page.goto("/sites/alpha/charts");
await expect(page.getByText(/author the default dashboard/i)).toBeVisible();
+ await expect(page.getByLabel("Active site")).toHaveValue("alpha");
+ await page.getByLabel("Active site").selectOption("beta");
+ await expect(page).toHaveURL(/\/sites\/beta\/charts$/);
+ await expect(page.getByText(/author the default dashboard/i)).toContainText(
+ "beta",
+ );
+ // The path wrote localStorage, so the scoped pages follow.
+ await page.goto("/channels");
+ await expect(page.getByLabel("Active site")).toHaveValue("beta");
+ await expect(page).toHaveURL(/site=beta/);
+ // "All sites" is the family page.
+ await page.goto("/sites/beta/charts");
+ await page.getByLabel("Active site").selectOption("__all__");
+ await expect(page).toHaveURL(/\/sites$/);
});
test("deploy targets the active site and is disabled under all sites", async ({
diff --git a/editor/next.config.ts b/editor/next.config.ts
@@ -1,6 +1,15 @@
import path from "node:path";
import type { NextConfig } from "next";
+// A ?site=<id> bookmark to one of the retired Sites satellites, matched at the
+// config level so it lands on that site's tab. The value is SITE_ID_RE; Next
+// anchors it, so ?site=__all__ misses and falls through to the bare rule.
+const siteQuery = {
+ type: "query",
+ key: "site",
+ value: "(?<site>[a-z0-9][a-z0-9-]*)",
+} as const;
+
const nextConfig: NextConfig = {
turbopack: {
// Pin the workspace root. Turbopack infers it by walking up for a lockfile
@@ -63,6 +72,17 @@ const nextConfig: NextConfig = {
// tail is paged. /api/jobs/active is NOT redirected: the dashboard, the widget
// and the page itself poll it.
//
+ // /charts, /aliases, /deploy, /build and /homepage were the Sites group's
+ // five satellites. A site's charts, search aliases and publish controls are
+ // its TABS now (/sites/<id>/{charts,aliases,publish}); what applies to the
+ // whole family — the release notes, the batch build, the hub, the pool —
+ // is on /sites itself. The `has` rules carry a ?site=<id> bookmark to the
+ // site's tab: the value regex is SITE_ID_RE, anchored by Next, so
+ // ?site=__all__ (underscores) misses it and lands on the family page. The
+ // matched query is NOT stripped — /charts?site=a lands on
+ // /sites/a/charts?site=a — which the tab ignores and the picker reconciles.
+ // Order matters: first match wins, so each `has` rule precedes its bare one.
+ //
// TEMPORARY, not permanent: a 308 is cached by the browser forever, and this
// is a self-hosted admin surface where a wrong permanent redirect is a
// support call with no remedy but a profile wipe. `permanent: false` is a
@@ -76,6 +96,10 @@ const nextConfig: NextConfig = {
{ source: "/scheduler", destination: "/operations/sync", permanent: false },
{ source: "/jobs/active", destination: "/jobs", permanent: false },
{ source: "/jobs/queue", destination: "/jobs", permanent: false },
+ { source: "/charts", has: [siteQuery], destination: "/sites/:site/charts", permanent: false },
+ { source: "/charts", destination: "/sites", permanent: false },
+ { source: "/aliases", has: [siteQuery], destination: "/sites/:site/aliases", permanent: false },
+ { source: "/aliases", destination: "/sites", permanent: false },
];
},
// Serve the built export artifacts (stats/summaries/transcripts) through a