Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Deditor/app/aliases/EditorAliasesClient.tsx | 361-------------------------------------------------------------------------------
Deditor/app/aliases/page.tsx | 62--------------------------------------------------------------
Meditor/app/build/buildAction.ts | 3++-
Deditor/app/charts/EditorChartsClient.tsx | 78------------------------------------------------------------------------------
Deditor/app/charts/page.tsx | 71-----------------------------------------------------------------------
Meditor/app/components/SiteScopeSelect.tsx | 37++++++++++++++++++++++++++++---------
Aeditor/app/lib/activeSite.test.ts | 39+++++++++++++++++++++++++++++++++++++++
Meditor/app/lib/activeSite.ts | 30+++++++++++++++++++++++++-----
Meditor/app/lib/nav.ts | 10+++-------
Aeditor/app/sites/[siteId]/aliases/page.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/[siteId]/charts/page.tsx | 36++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/[siteId]/components/SiteTabs.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/[siteId]/layout.tsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/[siteId]/lib/siteCache.ts | 21+++++++++++++++++++++
Meditor/app/sites/[siteId]/page.tsx | 41++++-------------------------------------
Meditor/app/sites/actions.ts | 4+++-
Aeditor/app/sites/components/EditorAliasesClient.tsx | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/EditorChartsClient.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Reditor/app/aliases/actions.ts -> editor/app/sites/lib/aliasesActions.ts | 0
Reditor/app/charts/actions.ts -> editor/app/sites/lib/chartsActions.ts | 0
Meditor/e2e/aliases.spec.ts | 12+++++++-----
Meditor/e2e/navigation.spec.ts | 19++++++++++++++++++-
Meditor/e2e/site-scope.spec.ts | 23++++++++++++++++++-----
Meditor/next.config.ts | 24++++++++++++++++++++++++
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&apos;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&apos;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&apos;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&apos;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&apos;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