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:
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