Archilyzer · Source

archilyzer

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

commit beae1f1357a9557a74cce95df440507440819b26
parent eec2f36df6d2b02f6b17ff6ee36315efcbd8b1f1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 22:45:34 -0400

editor: the active site is a cookie, read once by the root layout and supplied through SiteScopeProvider

The picker painted "All sites" on every navigation and then snapped to the
stored site: its value came from the URL, the stored choice was in
localStorage, read in an effect after paint that rewrote ?site= onto the URL.

- The store is a cookie (`archilyzer-active-site-<port>`: path=/, SameSite=Lax,
  one year, httpOnly; the port keeps two editors on one host apart, as
  localStorage's origin did). Its one writer is `setActiveSiteAction`
  (app/lib/activeSiteActions.ts); the cookie write re-renders the page and
  layouts, so Dashboard and Channels re-scope with no refresh of ours.
- One read: `readActiveSite()` (app/lib/activeSiteServer.ts, server-only),
  used by the root layout, Dashboard and Channels. A valid `?site=` still
  governs its own request and is not stored.
- The root layout mounts `SiteScopeProvider` with the cookie's selection; the
  picker renders from it plus the path and the URL, with no effect that
  rewrites the URL and no storage read. On /sites/<id>/… the path still wins;
  visiting one records the site through the same action.
- The one-time migration: a visitor with the old localStorage key and no
  cookie has it copied into the cookie, then the key is removed. It is the only
  localStorage read.
- The precedence is one pure function, `resolveActiveSiteFrom`; the cookie
  rules (`activeSiteCookieName`, `isStorableActiveSite`, `withoutSiteParam`)
  are unit-tested (editor unit 87 → 95).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/page.tsx | 5+++--
Aeditor/app/components/SiteScopeProvider.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/SiteScopeSelect.tsx | 116+++++++++++++++++++++++++++++++++++--------------------------------------------
Meditor/app/layout.tsx | 19+++++++++++++++++++
Meditor/app/lib/activeSite.test.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/lib/activeSite.ts | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Aeditor/app/lib/activeSiteActions.ts | 31+++++++++++++++++++++++++++++++
Aeditor/app/lib/activeSiteServer.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/app/page.tsx | 5+++--
9 files changed, 470 insertions(+), 84 deletions(-)

diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -52,7 +52,7 @@ import { } from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; -import { resolveActiveSite } from "../lib/activeSite"; +import { readActiveSite } from "../lib/activeSiteServer"; export const dynamic = "force-dynamic"; @@ -163,7 +163,8 @@ export default async function ChannelsPage({ const paths = getPaths(); const settings = getSettings(); const { site, location: locationParam, sort: sortParam } = await searchParams; - const active = resolveActiveSite(site, listSiteIds(paths)); + // The active site: a `?site=` link's for this request, else the cookie's. + const active = await readActiveSite(site, listSiteIds(paths)); // Counts come from each channel's last snapshot, not a corpus walk. One read // serves both the table and the freshness footer below. const briefs = await listChannelBriefs(paths); diff --git a/editor/app/components/SiteScopeProvider.tsx b/editor/app/components/SiteScopeProvider.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { usePathname } from "next/navigation"; +import { + ACTIVE_SITE_KEY, + isStorableActiveSite, + siteIdFromPathname, +} from "../lib/activeSite"; +import { setActiveSiteAction } from "../lib/activeSiteActions"; + +// THE SITE SCOPE, supplied once by the root layout (see app/lib/activeSite.ts). +// +// `activeSite` is the cookie's selection, resolved by the layout's +// readActiveSite() on the server, so the server and the first client render +// agree and the picker paints the stored site at once. `stored` below starts +// from it and moves ahead of it optimistically when a choice is made; when the +// server's value changes (the re-render a cookie write triggers, or a refresh +// after another tab chose), it follows that. +// +// Two effects, and neither rewrites a URL: +// - visiting a site's own page (/sites/<id>/…) records that site, so Dashboard +// and Channels follow — the path already shows it, so there is no flash; +// - the one-time MIGRATION: a visitor with the old localStorage key and no +// cookie gets it copied into the cookie, then the key is removed. That is the +// only localStorage read, and the one paint it may cost is on the first visit +// after the update. +type SiteScope = { + siteIds: string[]; + // The stored selection: a site id or "__all__". The picker resolves it + // against the path and a `?site=` param; it is not the effective scope alone. + stored: string; + // Store a choice: shown at once, written through setActiveSiteAction. Resolves + // once the cookie is set (and the server has re-rendered with it) to true, or + // to false with the previous selection put back when the write failed. + choose: (value: string) => Promise<boolean>; +}; + +const SiteScopeContext = createContext<SiteScope | null>(null); + +export function SiteScopeProvider({ + activeSite, + fromCookie, + siteIds, + children, +}: { + activeSite: string; + // Whether the request carried the cookie at all (the migration's trigger). + fromCookie: boolean; + siteIds: string[]; + children: ReactNode; +}) { + const [stored, setStored] = useState(activeSite); + // Follow the server's value when IT changes — not on every render, so an + // optimistic choice is not undone by a refresh that raced the cookie write — + // and not while a write is in flight: two quick choices re-render twice, and + // the first one's render must not paint over the second choice. + const [seen, setSeen] = useState(activeSite); + const [writing, setWriting] = useState(0); + if (seen !== activeSite) { + setSeen(activeSite); + if (writing === 0) setStored(activeSite); + } + // What `stored` is now, for the effects below without making them re-run on + // it. Synced after commit (declared first, so it runs before them), and moved + // at once by `choose`. + const storedRef = useRef(stored); + useEffect(() => { + storedRef.current = stored; + }, [stored]); + + const choose = useCallback(async (value: string): Promise<boolean> => { + const previous = storedRef.current; + storedRef.current = value; + setStored(value); + setWriting((n) => n + 1); + let ok = false; + try { + ok = await setActiveSiteAction(value); + } catch { + ok = false; + } + setWriting((n) => n - 1); + if (!ok) { + storedRef.current = previous; + setStored(previous); + } + return ok; + }, []); + + // Visiting a site's own page records it. + const pathname = usePathname(); + const pathSite = siteIdFromPathname(pathname)?.siteId ?? null; + const known = pathSite !== null && siteIds.includes(pathSite); + useEffect(() => { + if (!known || pathSite === storedRef.current) return; + void choose(pathSite as string); + }, [known, pathSite, choose]); + + // The one-time move from localStorage. Guarded by a ref, not by deps: it is + // about this page load, and StrictMode's second mount must not repeat it. + const migrated = useRef(false); + useEffect(() => { + if (migrated.current) return; + migrated.current = true; + const forget = () => { + try { + window.localStorage.removeItem(ACTIVE_SITE_KEY); + } catch { + /* storage unavailable: nothing to forget */ + } + }; + // A cookie already decides, and on a site's page the path does (the effect + // above records it): the old key has nothing left to say. + if (fromCookie || known) { + forget(); + return; + } + let legacy: string | null = null; + try { + legacy = window.localStorage.getItem(ACTIVE_SITE_KEY); + } catch { + /* storage unavailable */ + } + if (!isStorableActiveSite(legacy)) { + forget(); + return; + } + // Removed only once the cookie holds it, so a failed write loses nothing. + void choose(legacy).then((ok) => { + if (ok) forget(); + }); + // Once per page load, on purpose (see the ref). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const value = useMemo( + () => ({ siteIds, stored, choose }), + [siteIds, stored, choose], + ); + return ( + <SiteScopeContext.Provider value={value}> + {children} + </SiteScopeContext.Provider> + ); +} + +export function useSiteScope(): SiteScope { + const scope = useContext(SiteScopeContext); + if (!scope) { + throw new Error("useSiteScope() outside SiteScopeProvider (app/layout.tsx)"); + } + return scope; +} diff --git a/editor/app/components/SiteScopeSelect.tsx b/editor/app/components/SiteScopeSelect.tsx @@ -1,84 +1,59 @@ "use client"; -import { useEffect } from "react"; +import { useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { - ACTIVE_SITE_KEY, ALL_SITES, - resolveActiveSite, + resolveActiveSiteFrom, siteIdFromPathname, + withoutSiteParam, } from "../lib/activeSite"; +import { useSiteScope } from "./SiteScopeProvider"; 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) 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 +// The single global site selector shown in the sidebar. The stored selection is +// a cookie, read by the root layout and supplied by SiteScopeProvider, so this +// renders the right value on the FIRST paint, on the server and on every +// navigation — it has no effect that rewrites the URL and reads no storage. See // app/lib/activeSite.ts. +// +// What it shows, first match wins: the choice just made on this URL (until the +// navigation it starts lands), the site a /sites/<id>/… path names, a valid +// `?site=` on the URL (a link's scope for that page), the stored selection. 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 { siteIds, stored, choose } = useSiteScope(); + const search = searchParams.toString(); + const param = searchParams.get("site"); const onSite = siteIdFromPathname(pathname); - const resolved = resolveActiveSite(onSite?.siteId ?? urlValue, siteIds); + // A controlled <select> snaps back to its value when the change does not + // re-render it with the new one. Where the path or the param outranks the + // stored value, the choice is held here for the URL it was made on, so the + // select shows it until the navigation it starts replaces that URL. + const urlKey = `${pathname}?${search}`; + const [pending, setPending] = useState<{ urlKey: string; value: string } | null>( + null, + ); + const held = pending?.urlKey === urlKey ? pending.value : null; + const resolved = resolveActiveSiteFrom( + [held, onSite?.siteId, param, stored], + 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) 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) { - const params = new URLSearchParams(searchParams.toString()); - params.set("site", value); - router.replace(`${pathname}?${params.toString()}`, { scroll: false }); - } - // Keep URL and localStorage reconciled. An explicit, valid URL param wins (and - // is written back to localStorage); otherwise seed the URL from the stored - // preference so server components pick up the active site on navigation. - useEffect(() => { - if (siteIds.length === 0) return; - // 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, chosen as string); - } catch { - /* ignore */ - } - return; - } - if (!seedsSiteParam) return; - let stored: string | null = null; - try { - stored = window.localStorage.getItem(ACTIVE_SITE_KEY); - } catch { - /* ignore */ - } - setParam(resolveActiveSite(stored, siteIds).value); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlValue, pathname, siteIds.join(",")]); - - function onChange(value: string) { - try { - window.localStorage.setItem(ACTIVE_SITE_KEY, value); - } catch { - /* ignore */ - } + async function onChange(value: string) { 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". + // The site is the path here, so changing it is a navigation: the same tab + // of the other site, or the family page for "All sites". The cookie is + // written FIRST, so a page opened right after the URL moves reads it. + setPending({ urlKey, value }); + if (!(await choose(value))) { + setPending(null); + return; + } router.push( value === ALL_SITES ? "/sites" @@ -86,7 +61,20 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { ); return; } - setParam(value); + if (param === null) { + // The common case: the stored value is what shows, and the cookie write + // re-renders this page (Dashboard, Channels) in the new scope. + await choose(value); + return; + } + // A `?site=` link's page: the choice replaces the link's scope, so the + // param goes — after the cookie is written, so the new URL renders with it. + setPending({ urlKey, value }); + if (!(await choose(value))) { + setPending(null); + return; + } + router.replace(withoutSiteParam(pathname, search), { scroll: false }); } if (sites.length === 0) { @@ -102,7 +90,7 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { <span className="text-xs uppercase tracking-wide text-muted-foreground">site</span> <select value={resolved.value} - onChange={(e) => onChange(e.target.value)} + onChange={(e) => void onChange(e.target.value)} aria-label="Active site" className="rounded border border-border bg-card px-2 py-1 text-sm" > diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -18,6 +18,8 @@ import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; import { ChangelogNavLink } from "./components/ChangelogNavLink"; import { SiteScopeSelect } from "./components/SiteScopeSelect"; +import { SiteScopeProvider } from "./components/SiteScopeProvider"; +import { readActiveSite } from "./lib/activeSiteServer"; import { Toaster } from "yt-dlp-transcript-common/components/ui/sonner"; import { NAV_GROUPS, type NavLink } from "./lib/nav"; import { CleanableBadge } from "./components/CleanableBadge"; @@ -62,6 +64,12 @@ export default async function RootLayout({ siteId: s.siteId, siteTitle: s.siteTitle, })); + const siteIds = sites.map((s) => s.siteId); + // THE SITE SCOPE, read once per request from its cookie and supplied to the + // picker (and anything else that asks) by SiteScopeProvider. A layout has no + // searchParams, so a `?site=` link is applied by the picker from the URL and + // by the scoped pages from their own searchParams. See app/lib/activeSite.ts. + const scope = await readActiveSite(null, siteIds); const navItemClass = "px-2.5 py-1.5 rounded-md text-foreground/80 hover:text-foreground hover:bg-muted whitespace-nowrap flex items-center gap-2 transition-colors"; const renderLink = (link: NavLink) => { @@ -128,6 +136,11 @@ export default async function RootLayout({ <body className="min-h-full flex flex-col md:flex-row bg-background text-foreground"> <ThemeScript /> <ThemeProvider> + <SiteScopeProvider + activeSite={scope.value} + fromCookie={scope.stored !== null} + siteIds={siteIds} + > <AppFrame sidebar={ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-border bg-card flex flex-col"> @@ -155,6 +168,11 @@ export default async function RootLayout({ <ThemeToggle /> </div> </div> + {/* The picker reads useSearchParams() (a `?site=` link). This + layout reads a cookie, so every route renders per request and + the hook does not suspend on the server: the fallback is never + what the first paint shows. The boundary stays for the + production build's missing-Suspense check. */} <Suspense fallback={null}> <SiteScopeSelect sites={sites} /> </Suspense> @@ -196,6 +214,7 @@ export default async function RootLayout({ > {children} </AppFrame> + </SiteScopeProvider> </ThemeProvider> </body> </html> diff --git a/editor/app/lib/activeSite.test.ts b/editor/app/lib/activeSite.test.ts @@ -1,6 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { siteIdFromPathname } from "./activeSite"; +import { + ACTIVE_SITE_COOKIE, + ALL_SITES, + activeSiteCookieName, + isStorableActiveSite, + resolveActiveSiteFrom, + siteIdFromPathname, + withoutSiteParam, +} from "./activeSite"; // Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" // @@ -37,3 +45,78 @@ test("routes outside /sites name no site", () => { test("a deeper path than a tab names no site", () => { assert.equal(siteIdFromPathname("/sites/a/b/c"), null); }); + +// ── The cookie (release 15 slice SS) ───────────────────────────────────────── +// +// The store is a cookie read on the server; these are its pure rules: the name a +// request uses, what may be written, and which value wins. + +test("the cookie name carries the port, so two editors on one host keep one each", () => { + assert.equal(activeSiteCookieName("localhost:3001"), `${ACTIVE_SITE_COOKIE}-3001`); + assert.equal(activeSiteCookieName("127.0.0.1:3411"), `${ACTIVE_SITE_COOKIE}-3411`); + assert.equal(activeSiteCookieName("[::1]:3101"), `${ACTIVE_SITE_COOKIE}-3101`); +}); + +test("a host with no port, or no host, uses the base name", () => { + assert.equal(activeSiteCookieName("editor.example.org"), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName(null), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName(undefined), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName("[::1]"), ACTIVE_SITE_COOKIE); +}); + +test("a site id or the all-sites sentinel may be stored", () => { + assert.equal(isStorableActiveSite("alpha"), true); + assert.equal(isStorableActiveSite("a-1"), true); + assert.equal(isStorableActiveSite(ALL_SITES), true); +}); + +test("anything else is refused: the value goes into a header", () => { + for (const bad of [ + "", + "Alpha", + "-alpha", + "a b", + "a;b", + "a=b", + "a\r\nSet-Cookie: x=y", + "_homepage", + "a".repeat(129), + null, + undefined, + 42, + ]) { + assert.equal(isStorableActiveSite(bad), false, JSON.stringify(bad)); + } + assert.equal(isStorableActiveSite("a".repeat(128)), true); +}); + +test("the first candidate naming a configured site wins", () => { + const ids = ["alpha", "beta"]; + // A `?site=` link beats the cookie. + assert.equal(resolveActiveSiteFrom(["beta", "alpha"], ids).value, "beta"); + // No param: the cookie. + assert.equal(resolveActiveSiteFrom([null, "alpha"], ids).value, "alpha"); + assert.equal(resolveActiveSiteFrom([undefined, "alpha"], ids).siteId, "alpha"); + // The sentinel is a real choice, not a miss. + assert.equal(resolveActiveSiteFrom([ALL_SITES, "alpha"], ids).isAll, true); +}); + +test("a param naming no site falls through to the cookie", () => { + const ids = ["alpha", "beta"]; + assert.equal(resolveActiveSiteFrom(["gone", "beta"], ids).value, "beta"); +}); + +test("nothing valid resolves to the default: the lone site, else all sites", () => { + assert.equal(resolveActiveSiteFrom([null, "gone"], ["alpha", "beta"]).value, ALL_SITES); + assert.equal(resolveActiveSiteFrom([null, null], ["solo"]).value, "solo"); + assert.equal(resolveActiveSiteFrom([], []).value, ALL_SITES); +}); + +test("dropping the site param keeps every other param", () => { + assert.equal(withoutSiteParam("/channels", "site=alpha"), "/channels"); + assert.equal( + withoutSiteParam("/channels", "site=alpha&location=internal&sort=size"), + "/channels?location=internal&sort=size", + ); + assert.equal(withoutSiteParam("/", ""), "/"); +}); diff --git a/editor/app/lib/activeSite.ts b/editor/app/lib/activeSite.ts @@ -1,25 +1,68 @@ // Shared notion of "the site I'm working on" for the editor's site-scoped views -// (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. +// (Dashboard, Channels) and the sidebar's "Active site" picker. This module has +// NO server-only imports so the client picker and provider can share it. // -// 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. +// THE STORE IS A COOKIE (release 15 slice SS). The root layout reads it once per +// request (`readActiveSite()`, app/lib/activeSiteServer.ts) and hands it to +// `SiteScopeProvider`; Dashboard and Channels read it through the same helper. +// So the server renders the picker, and the scoped pages, with the stored +// selection on the first paint — nothing is reconciled after it. It is written +// only by `setActiveSiteAction` (app/lib/activeSiteActions.ts). +// +// A `?site=<id>` LINK still works: a valid param governs that one request (the +// server pages read it from `searchParams`, the picker from the URL), and is not +// written to the cookie. The editor's own navigation no longer appends it. +// +// ON A SITE'S OWN PAGES the site is the PATH, not the cookie or 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. +// Visiting one records that site in the cookie, so Dashboard and Channels follow. -// 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). +// Sentinel value meaning "all sites" (full channel pool). A bare string so it +// can never collide with a real siteId (which matches SITE_ID_RE). export const ALL_SITES = "__all__"; -// localStorage key the selector reads/writes. +// The localStorage key the picker used before the cookie. Read ONCE, by +// SiteScopeProvider's migration, when a visitor has it and no cookie; then +// removed. Nothing else reads it. export const ACTIVE_SITE_KEY = "activeSite"; +// The cookie's base name. The name a request uses carries the port it was made +// to (`activeSiteCookieName`), because a cookie is shared by every port on a +// host while localStorage was per origin: two editors on one machine (the live +// one and a worktree's) keep a selection each, as they did. +export const ACTIVE_SITE_COOKIE = "archilyzer-active-site"; + +// One year, in seconds (the cookie's `maxAge`). +export const ACTIVE_SITE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +// "localhost:3001" → "archilyzer-active-site-3001"; a host with no port (the +// default port, behind a proxy) or no host at all → the base name. +export function activeSiteCookieName(host: string | null | undefined): string { + const port = host ? /:(\d+)$/.exec(host)?.[1] : undefined; + return port ? `${ACTIVE_SITE_COOKIE}-${port}` : ACTIVE_SITE_COOKIE; +} + +// SITE_ID_RE re-spelled (common/lib/siteSchema.ts imports zod and is +// server-only), with a length cap because the value goes into a header. +const SITE_ID_SHAPE = /^[a-z0-9][a-z0-9-]*$/; +const STORED_MAX_LENGTH = 128; + +// What may be stored: ALL_SITES or anything shaped like a site id. Whether the +// id names a site is decided when it is read (`resolveActiveSite`), so a site +// deleted since resolves as a missing selection does. +export function isStorableActiveSite(value: unknown): value is string { + return ( + typeof value === "string" && + (value === ALL_SITES || + (value.length <= STORED_MAX_LENGTH && SITE_ID_SHAPE.test(value))) + ); +} + export type ResolvedActiveSite = { // All configured site ids (passed in by the caller). siteIds: string[]; - // The canonical param value for the resolved selection: a siteId or ALL_SITES. + // The canonical value for the resolved selection: a siteId or ALL_SITES. value: string; // True when the selection spans every site (full pool). isAll: boolean; @@ -27,7 +70,7 @@ export type ResolvedActiveSite = { siteId: string | null; }; -// Resolve the raw `searchParams.site` value against the configured site ids. +// Resolve one raw value (a param, a cookie) against the configured site ids. // - a valid siteId -> that site // - ALL_SITES -> all sites (full pool) // - missing/invalid (default) -> the lone site if exactly one, else all sites @@ -54,10 +97,23 @@ export function resolveActiveSite( return all; } +// THE PRECEDENCE, in one place: the first candidate that names a configured +// site (or ALL_SITES) wins; when none does, the default above. The server +// passes [?site=, cookie]; the picker passes [its pending choice, the path's +// site, ?site=, the stored value]. +export function resolveActiveSiteFrom( + candidates: ReadonlyArray<string | null | undefined>, + siteIds: string[], +): ResolvedActiveSite { + const chosen = candidates.find( + (c) => c === ALL_SITES || (!!c && siteIds.includes(c)), + ); + return resolveActiveSite(chosen, siteIds); +} + // 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. +// SITE_ID_RE re-spelled, as above. const SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/; export type SitePath = { siteId: string; segment: string | null }; @@ -69,3 +125,12 @@ export function siteIdFromPathname(pathname: string): SitePath | null { if (!m || m[1] === "new") return null; return { siteId: m[1], segment: m[2] ?? null }; } + +// The same URL with its `site` param dropped (every other param kept, in +// order): where the picker goes when a choice replaces a `?site=` link's scope. +export function withoutSiteParam(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete("site"); + const q = params.toString(); + return q ? `${pathname}?${q}` : pathname; +} diff --git a/editor/app/lib/activeSiteActions.ts b/editor/app/lib/activeSiteActions.ts @@ -0,0 +1,31 @@ +"use server"; + +import { cookies } from "next/headers"; +import { + ACTIVE_SITE_COOKIE_MAX_AGE, + isStorableActiveSite, +} from "./activeSite"; +import { activeSiteCookieForRequest } from "./activeSiteServer"; + +// THE ONE WRITER of the active-site cookie (see ./activeSite.ts). Called by the +// picker's onChange, by SiteScopeProvider when a site's own page is visited, and +// once by its localStorage migration. Setting a cookie in a server action makes +// Next re-render the current page and its layouts with the new value, so the +// root layout hands the provider the new selection and Dashboard or Channels +// re-scope, with no refresh of our own. +// +// The value is only SHAPE-checked (a site id or "__all__"): whether it names a +// configured site is decided at every read, so a site deleted later resolves as +// no selection. Returns false, writing nothing, for anything else. +export async function setActiveSiteAction(value: string): Promise<boolean> { + if (!isStorableActiveSite(value)) return false; + const name = await activeSiteCookieForRequest(); + (await cookies()).set(name, value, { + path: "/", + sameSite: "lax", + maxAge: ACTIVE_SITE_COOKIE_MAX_AGE, + // Only the server reads it: the page gets it through the layout. + httpOnly: true, + }); + return true; +} diff --git a/editor/app/lib/activeSiteServer.ts b/editor/app/lib/activeSiteServer.ts @@ -0,0 +1,35 @@ +import "server-only"; +import { cookies, headers } from "next/headers"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { listSiteIds } from "yt-dlp-transcript-common/lib/site"; +import { + activeSiteCookieName, + resolveActiveSiteFrom, + type ResolvedActiveSite, +} from "./activeSite"; + +// THE ONE READ of the active site on the server (see ./activeSite.ts). The root +// layout calls it with no param (a layout has no searchParams) and hands the +// result to SiteScopeProvider; Dashboard and Channels call it with their +// `searchParams.site`, which wins for that request when it names a site or +// "__all__". Nothing is written here: a server component cannot set a cookie, +// and only setActiveSiteAction does. +export type ActiveSiteRead = ResolvedActiveSite & { + // The cookie's raw value, or null when the request carried none — which is + // what tells the provider a visitor may still have the old localStorage key. + stored: string | null; +}; + +// The request's own cookie name (per port; see activeSiteCookieName). +export async function activeSiteCookieForRequest(): Promise<string> { + return activeSiteCookieName((await headers()).get("host")); +} + +export async function readActiveSite( + param?: string | null, + siteIds: string[] = listSiteIds(getPaths()), +): Promise<ActiveSiteRead> { + const name = await activeSiteCookieForRequest(); + const stored = (await cookies()).get(name)?.value ?? null; + return { ...resolveActiveSiteFrom([param, stored], siteIds), stored }; +} diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -13,7 +13,7 @@ import { getActionableSummary, widgetActionableRows, } from "./lib/actionable/loadActionable"; -import { resolveActiveSite } from "./lib/activeSite"; +import { readActiveSite } from "./lib/activeSiteServer"; import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs"; import { buildWorkersPayload } from "./workers/buildWorkers"; import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; @@ -52,8 +52,9 @@ export default async function Dashboard({ searchParams: Promise<{ site?: string }>; }) { const paths = getPaths(); + // The active site: a `?site=` link's for this request, else the cookie's. const { site } = await searchParams; - const active = resolveActiveSite(site, listSiteIds(paths)); + const active = await readActiveSite(site, listSiteIds(paths)); const summary = await getActionableSummary(paths); // Scope the channel-derived data to the active site's membership. Under "all