commit e610eca55b821e29641f56e3e9d2222154cc310e
parent dd3b14ce1c9fcf25e9d929c90975bd73265e8cd3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 31 May 2026 18:45:12 -0400
global multi-site selector
Diffstat:
18 files changed, 543 insertions(+), 153 deletions(-)
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -168,6 +168,12 @@ export function parseSite(siteId: string, raw: unknown): Site {
};
}
+// The set of channel slugs a site exposes. Used to scope the editor's
+// site-specific views (dashboard, channels) down to one site's membership.
+export function siteChannelSlugs(site: Site): Set<string> {
+ return new Set(site.channels.map((c) => c.slug));
+}
+
export function getSite(siteId: string, paths: Paths = getPaths()): Site {
if (!isValidSiteId(siteId)) {
throw new Error(`Invalid site id: ${String(siteId)}`);
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **One global site selector scopes the editor to a single site.** The sidebar gained a single **site** dropdown (with an **All sites** option) that scopes the **Dashboard**, **Channels**, **Charts**, and **Deploy** views to the chosen site: Dashboard stats and the channel table, and the Channels list, now show only that site's member channels; Charts edits that site's dashboard; and Build static export / Deploy target it. Views that act on the shared pool — **Jobs**, **Active**, **Build**, and **Actionable** — always show every site regardless of the selector. The nav is regrouped to match: a **Site** group first, then **Pool**, then **Manage**. The selection persists in `localStorage` and is mirrored into the `?site=` URL param. Under "All sites", Charts and Deploy ask you to pick a specific site; creating a channel while a specific site is active also adds it to that site's membership. This replaces the per-page site tabs on Charts and the per-button site dropdowns on Deploy.
- **Date-range filter on the public site's search.** The exported site's search filters gained a **Date** row (From / To pickers) that restricts results to videos uploaded within an inclusive range. The range saves in profiles and shareable search links and carries over to "Chart this search", reusing the same date mechanism the charts dashboard already uses. No editor-chrome change; it ships in every built site.
- **Bulk checkbox transcribe/retry now queue like every other batch.** Selecting videos in the channel's list and clicking **Transcribe** (or **Retry download**) used to fire one queued single-video job per selection onto the channel's *platform* queue — diverging from "Transcribe missing"/"Download", which submit one batch job on the shared `transcription`/platform queue. The checkbox actions now submit a **single** batch job through the same path (so bulk and the stage buttons serialize together instead of contending), and the resulting job shows up in the page's running-jobs list. The selection bar gained the matching controls: a **queue** selector for each action (defaulting to `transcription` for transcribe and the platform queue for retry), a **Parallel** concurrency input for transcribe, and an **Abort on error** toggle for retry. Default-queue resolution for all batch features now lives in one shared helper (`common/lib/queueKeys.ts`) so they can't drift apart again. "Mark untranscribable" is unchanged (instant metadata write).
- **Shard controls overhauled: "Save" button, 1-based `i / N` inputs, remaining-only transcribe slices, and always-on logging.** The shard inputs now read **`i / N`** (this shard's number first, 1-based — a 2-way split is `1/2` and `2/2`) and each box has its own hover title so they're no longer easy to mix up. A new **Save** button persists a slice to `shard-<op>.json` *without* starting the job — handy for dividing the remaining videos across machines (that share an identical synced copy) up front, and for confirming the slice actually saved; the saved-shard pill updates immediately. **Transcribe** sharding now splits only the videos that still need a transcript (e.g. 6 untranscribed videos split 2 ways → 3 each) instead of slicing the whole channel's video list. And every Download/Transcribe/Availability run now logs whether it's running a computed/saved shard slice or the full set, so a run that silently ignored shard inputs is no longer indistinguishable from one that honored it.
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -13,6 +13,12 @@ import {
writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import {
+ getSite,
+ isValidSiteId,
+ listSiteIds,
+ writeSite,
+} from "yt-dlp-transcript-common/lib/site";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import {
CHANNEL_FORM_FIELDS,
@@ -42,6 +48,23 @@ export async function createChannelAction(
return { error: `Channel "${slug}" already exists` };
}
await createChannel(paths, slug, config);
+ // When created under a specific active site, add the new channel to that
+ // site's membership so it's visible in the scoped Channels list. No-op under
+ // "all sites" (the hidden field is omitted by the form in that case).
+ const activeSite = formData.get("activeSite");
+ if (
+ typeof activeSite === "string" &&
+ isValidSiteId(activeSite) &&
+ listSiteIds(paths).includes(activeSite)
+ ) {
+ const site = getSite(activeSite, paths);
+ if (!site.channels.some((c) => c.slug === slug)) {
+ await writeSite(
+ { ...site, channels: [...site.channels, { slug }] },
+ paths,
+ );
+ }
+ }
revalidatePath("/channels");
revalidatePath("/");
redirect(`/channels/${slug}`);
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -16,6 +16,9 @@ type Props = {
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
errorMessage?: string;
+ // Extra hidden fields submitted with the form (e.g. the active site so a newly
+ // created channel can be added to that site's membership).
+ hiddenFields?: Record<string, string>;
};
export function ChannelForm({
@@ -23,11 +26,16 @@ export function ChannelForm({
initial,
submitLabel,
errorMessage,
+ hiddenFields,
}: Props) {
const isEdit = !!initial;
const c = initial?.config;
return (
<form action={action} className="flex flex-col gap-4 max-w-xl">
+ {hiddenFields &&
+ Object.entries(hiddenFields).map(([name, value]) => (
+ <input key={name} type="hidden" name={name} defaultValue={value} />
+ ))}
{errorMessage && (
<div className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{errorMessage}
diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx
@@ -1,9 +1,10 @@
"use client";
-import { useActionState } from "react";
+import { useActionState, useEffect, useState } from "react";
import { ChannelForm } from "./ChannelForm";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
import type { ActionResult } from "../actions";
+import { ALL_SITES } from "../../lib/activeSite";
type Props = {
action: (
@@ -19,12 +20,26 @@ export function ChannelFormClient({ action, initial, submitLabel }: Props) {
action,
undefined,
);
+ // The active site is mirrored into the `?site=` URL param by SiteScopeSelect.
+ // Read it here (avoiding useSearchParams so this form needn't be wrapped in a
+ // Suspense boundary) so a newly created channel joins that site's membership.
+ const [activeSite, setActiveSite] = useState<string | null>(null);
+ useEffect(() => {
+ try {
+ setActiveSite(new URLSearchParams(window.location.search).get("site"));
+ } catch {
+ /* ignore */
+ }
+ }, []);
+ const hiddenFields =
+ activeSite && activeSite !== ALL_SITES ? { activeSite } : undefined;
return (
<ChannelForm
action={formAction}
initial={initial}
submitLabel={submitLabel}
errorMessage={state?.error}
+ hiddenFields={hiddenFields}
/>
);
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -2,15 +2,36 @@ import type { Metadata } from "next";
import Link from "next/link";
import { listChannels } from "yt-dlp-transcript-common/controller/channels";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getSite,
+ listSiteIds,
+ siteChannelSlugs,
+} from "yt-dlp-transcript-common/lib/site";
import { ChannelsTable } from "./components/ChannelsTable";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
+import { resolveActiveSite } from "../lib/activeSite";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Channels" };
-export default async function ChannelsPage() {
- const channels = await listChannels(getPaths());
+export default async function ChannelsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ site?: string }>;
+}) {
+ const paths = getPaths();
+ const { site } = await searchParams;
+ const active = resolveActiveSite(site, listSiteIds(paths));
+ const all = await listChannels(paths);
+ // Scope to the active site's membership; "all sites" shows the full pool.
+ const channels =
+ active.isAll || !active.siteId
+ ? all
+ : (() => {
+ const slugs = siteChannelSlugs(getSite(active.siteId, paths));
+ return all.filter((c) => slugs.has(c.slug));
+ })();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@@ -27,7 +48,9 @@ export default async function ChannelsPage() {
</div>
{channels.length === 0 ? (
<p className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4">
- No channels yet.
+ {active.siteId && !active.isAll
+ ? `No channels assigned to ${active.siteId} yet — add some on the Sites page, or create a channel to add it here.`
+ : "No channels yet."}
</p>
) : (
<ChannelsTable channels={channels} />
diff --git a/editor/app/charts/page.tsx b/editor/app/charts/page.tsx
@@ -2,8 +2,9 @@ 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, defaultSiteId } from "yt-dlp-transcript-common/lib/site";
+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" };
@@ -32,34 +33,25 @@ export default async function ChartsPage({
);
}
- const siteId =
- site && siteIds.includes(site)
- ? site
- : (defaultSiteId(paths) ?? siteIds[0]);
+ 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-zinc-500">
+ 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">
- <div className="flex items-center justify-between gap-3 flex-wrap">
- <h1 className="text-2xl font-semibold">Charts</h1>
- {siteIds.length > 1 && (
- <nav className="flex items-center gap-1 text-sm">
- {siteIds.map((id) => (
- <Link
- key={id}
- href={`/charts?site=${id}`}
- className={`px-2 py-1 rounded border text-xs ${
- id === siteId
- ? "border-blue-400 bg-blue-50 dark:bg-blue-950"
- : "border-zinc-300 dark:border-zinc-700"
- }`}
- >
- {id}
- </Link>
- ))}
- </nav>
- )}
- </div>
+ <h1 className="text-2xl font-semibold">Charts</h1>
<p className="mt-1 text-sm text-zinc-500">
Author the default dashboard for <strong>{siteId}</strong>. Edits save
automatically and bake into that site's next export build. Requires
diff --git a/editor/app/components/SiteScopeSelect.tsx b/editor/app/components/SiteScopeSelect.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import {
+ ACTIVE_SITE_KEY,
+ ALL_SITES,
+ resolveActiveSite,
+} 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.
+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 multi = sites.length > 1;
+
+ 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;
+ const urlIsValid =
+ urlValue === ALL_SITES || (!!urlValue && siteIds.includes(urlValue));
+ if (urlIsValid) {
+ try {
+ window.localStorage.setItem(ACTIVE_SITE_KEY, urlValue as string);
+ } catch {
+ /* ignore */
+ }
+ 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 */
+ }
+ setParam(value);
+ }
+
+ if (sites.length === 0) {
+ return (
+ <div className="px-4 py-2 text-xs text-zinc-500">
+ No sites — create one under Sites.
+ </div>
+ );
+ }
+
+ return (
+ <label className="flex flex-col gap-1 px-4 py-2 text-sm">
+ <span className="text-xs uppercase tracking-wide text-zinc-500">site</span>
+ <select
+ value={resolved.value}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label="Active site"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {multi && <option value={ALL_SITES}>All sites</option>}
+ {sites.map((s) => (
+ <option key={s.siteId} value={s.siteId}>
+ {s.siteTitle} ({s.siteId})
+ </option>
+ ))}
+ </select>
+ </label>
+ );
+}
diff --git a/editor/app/deploy/components/BuildExportButton.tsx b/editor/app/deploy/components/BuildExportButton.tsx
@@ -5,28 +5,38 @@ import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActio
import { buildExportAction } from "../../build/buildAction";
import { QueueControl } from "../../components/QueueControl";
import { cancelJobAction } from "../../jobs/actions";
-import { SiteSelect, type SiteOption } from "./SiteSelect";
type Props = {
existingQueues: string[];
- sites: SiteOption[];
+ // The active site to build, or null under "all sites".
+ siteId: string | null;
+ siteTitle: string | null;
};
const DEFAULT_BUILD_QUEUE = "build";
-export function BuildExportButton({ existingQueues, sites }: Props) {
+export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) {
const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
- const [siteId, setSiteId] = useState(sites[0]?.siteId ?? "");
return (
<StreamActionLog
- trigger={() => buildExportAction(siteId, exportQueue)}
+ trigger={() => buildExportAction(siteId as string, exportQueue)}
cancelAction={cancelJobAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
+ disabled={!siteId}
extraControls={
<div className="flex flex-col gap-2">
- <SiteSelect sites={sites} value={siteId} onChange={setSiteId} />
+ {siteId ? (
+ <p className="text-sm text-zinc-500">
+ Building <strong>{siteTitle ?? siteId}</strong> (
+ <code>{siteId}</code>).
+ </p>
+ ) : (
+ <p className="text-sm text-amber-600 dark:text-amber-400">
+ Select a specific site from the sidebar to build.
+ </p>
+ )}
<QueueControl
value={exportQueue}
onChange={setExportQueue}
diff --git a/editor/app/deploy/components/DeployButton.tsx b/editor/app/deploy/components/DeployButton.tsx
@@ -1,22 +1,35 @@
"use client";
-import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
import { cancelJobAction } from "../../jobs/actions";
import { deployExportAction } from "../deployAction";
-import { SiteSelect, type SiteOption } from "./SiteSelect";
-export function DeployButton({ sites }: { sites: SiteOption[] }) {
- const [siteId, setSiteId] = useState(sites[0]?.siteId ?? "");
+type Props = {
+ // The active site to deploy, or null under "all sites".
+ siteId: string | null;
+ siteTitle: string | null;
+};
+
+export function DeployButton({ siteId, siteTitle }: Props) {
return (
<StreamActionLog
- trigger={() => deployExportAction(siteId)}
+ trigger={() => deployExportAction(siteId as string)}
cancelAction={cancelJobAction}
buttonLabel="Deploy"
runningLabel="Deploying…"
label="Deploy export"
+ disabled={!siteId}
extraControls={
- <SiteSelect sites={sites} value={siteId} onChange={setSiteId} />
+ siteId ? (
+ <p className="text-sm text-zinc-500">
+ Deploying <strong>{siteTitle ?? siteId}</strong> (
+ <code>{siteId}</code>).
+ </p>
+ ) : (
+ <p className="text-sm text-amber-600 dark:text-amber-400">
+ Select a specific site from the sidebar to deploy.
+ </p>
+ )
}
/>
);
diff --git a/editor/app/deploy/components/SiteSelect.tsx b/editor/app/deploy/components/SiteSelect.tsx
@@ -1,38 +0,0 @@
-"use client";
-
-export type SiteOption = { siteId: string; siteTitle: string };
-
-export function SiteSelect({
- sites,
- value,
- onChange,
-}: {
- sites: SiteOption[];
- value: string;
- onChange: (siteId: string) => void;
-}) {
- if (sites.length === 0) {
- return (
- <span className="text-xs text-red-600">
- No sites configured — create one under Sites.
- </span>
- );
- }
- return (
- <label className="flex items-center gap-2 text-sm">
- <span className="text-zinc-500">Site</span>
- <select
- value={value}
- onChange={(e) => onChange(e.target.value)}
- aria-label="Site"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- >
- {sites.map((s) => (
- <option key={s.siteId} value={s.siteId}>
- {s.siteTitle} ({s.siteId})
- </option>
- ))}
- </select>
- </label>
- );
-}
diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx
@@ -8,11 +8,12 @@ import {
suggestNextVersion,
} from "yt-dlp-transcript-common/lib/changelog";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { listSiteIds, listSites } from "yt-dlp-transcript-common/lib/site";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { BuildExportButton } from "./components/BuildExportButton";
import { CutReleaseForm } from "./components/CutReleaseForm";
import { DeployButton } from "./components/DeployButton";
+import { resolveActiveSite } from "../lib/activeSite";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Deploy" };
@@ -37,7 +38,12 @@ function getLastDeployedAt(): string | null {
}
}
-export default function DeployPage() {
+export default async function DeployPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ site?: string }>;
+}) {
+ const paths = getPaths();
const source = loadExportChangelog();
const lastVersion = source ? getLatestReleasedVersion(source) : null;
const suggestedVersion = suggestNextVersion(lastVersion);
@@ -45,10 +51,14 @@ export default function DeployPage() {
const hasUnreleased = unreleased.length > 0;
const lastDeployedAt = getLastDeployedAt();
const existingQueues = getRegistry().activeQueueNames();
- const sites = listSites(getPaths()).map((s) => ({
- siteId: s.siteId,
- siteTitle: s.siteTitle,
- }));
+ const { site } = await searchParams;
+ const active = resolveActiveSite(site, listSiteIds(paths));
+ // Build/deploy target the single active site; null under "all sites".
+ const activeSiteId = active.isAll ? null : active.siteId;
+ const activeSiteTitle = activeSiteId
+ ? (listSites(paths).find((s) => s.siteId === activeSiteId)?.siteTitle ??
+ activeSiteId)
+ : null;
return (
<div className="flex flex-col gap-8 max-w-3xl">
@@ -63,7 +73,11 @@ export default function DeployPage() {
static site at <code>export/out/</code>.
</p>
</div>
- <BuildExportButton existingQueues={existingQueues} sites={sites} />
+ <BuildExportButton
+ existingQueues={existingQueues}
+ siteId={activeSiteId}
+ siteTitle={activeSiteTitle}
+ />
</section>
<section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
@@ -118,7 +132,7 @@ export default function DeployPage() {
: "never"}
</p>
</div>
- <DeployButton sites={sites} />
+ <DeployButton siteId={activeSiteId} siteTitle={activeSiteTitle} />
</section>
</div>
);
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -6,8 +6,10 @@ import Link from "next/link";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
import { CommandPalette } from "./components/CommandPalette";
import { ChangelogNavLink } from "./components/ChangelogNavLink";
+import { SiteScopeSelect } from "./components/SiteScopeSelect";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
@@ -27,18 +29,40 @@ type NavLink = {
badgeKey?: "jobs" | "running" | "changelog";
};
-const NAV_LINKS: NavLink[] = [
- { href: "/", label: "Dashboard" },
- { href: "/sites", label: "Sites" },
- { href: "/channels", label: "Channels" },
- { href: "/actionable", label: "Actionable" },
- { href: "/jobs", label: "Jobs", badgeKey: "jobs" },
- { href: "/jobs/active", label: "Active", badgeKey: "running" },
- { href: "/build", label: "Build" },
- { href: "/charts", label: "Charts" },
- { href: "/deploy", label: "Deploy" },
- { href: "/settings", label: "Settings" },
- { href: "/changelog", label: "Changelog", badgeKey: "changelog" },
+type NavGroup = {
+ // Short label shown above the group. The active site selector scopes the
+ // "Site" group; "Pool" and "Manage" are unaffected (shared across all sites).
+ label: string;
+ links: NavLink[];
+};
+
+const NAV_GROUPS: NavGroup[] = [
+ {
+ label: "Site",
+ links: [
+ { href: "/", label: "Dashboard" },
+ { href: "/channels", label: "Channels" },
+ { href: "/charts", label: "Charts" },
+ { href: "/deploy", label: "Deploy" },
+ ],
+ },
+ {
+ label: "Pool",
+ links: [
+ { href: "/jobs", label: "Jobs", badgeKey: "jobs" },
+ { href: "/jobs/active", label: "Active", badgeKey: "running" },
+ { href: "/build", label: "Build" },
+ { href: "/actionable", label: "Actionable" },
+ ],
+ },
+ {
+ label: "Manage",
+ links: [
+ { href: "/sites", label: "Sites" },
+ { href: "/settings", label: "Settings" },
+ { href: "/changelog", label: "Changelog", badgeKey: "changelog" },
+ ],
+ },
];
function readChangelogLatestDate(): string | null {
@@ -66,6 +90,49 @@ export default async function RootLayout({
).length;
const runningJobs = allJobs.filter((j) => j.status === "running").length;
const latestChangelogDate = readChangelogLatestDate();
+ const sites = listSites().map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ }));
+ const navItemClass =
+ "px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2";
+ const renderLink = (link: NavLink) => {
+ if (link.badgeKey === "changelog") {
+ return (
+ <ChangelogNavLink
+ key={link.href}
+ href={link.href}
+ label={link.label}
+ latestDate={latestChangelogDate}
+ className={navItemClass}
+ />
+ );
+ }
+ const badgeCount =
+ link.badgeKey === "jobs"
+ ? activeJobs
+ : link.badgeKey === "running"
+ ? runningJobs
+ : 0;
+ const badgeNoun =
+ link.badgeKey === "running" ? "running job" : "active job";
+ const showBadge =
+ (link.badgeKey === "jobs" || link.badgeKey === "running") &&
+ badgeCount > 0;
+ return (
+ <Link key={link.href} href={link.href} className={navItemClass}>
+ <span>{link.label}</span>
+ {showBadge && (
+ <span
+ aria-label={`${badgeCount} ${badgeNoun}${badgeCount === 1 ? "" : "s"}`}
+ className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none"
+ >
+ {badgeCount}
+ </span>
+ )}
+ </Link>
+ );
+ };
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
@@ -81,50 +148,21 @@ export default async function RootLayout({
editor
</div>
</div>
+ <Suspense fallback={null}>
+ <SiteScopeSelect sites={sites} />
+ </Suspense>
<nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible">
- {NAV_LINKS.map((link) => {
- const navItemClass =
- "px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2";
- if (link.badgeKey === "changelog") {
- return (
- <ChangelogNavLink
- key={link.href}
- href={link.href}
- label={link.label}
- latestDate={latestChangelogDate}
- className={navItemClass}
- />
- );
- }
- const badgeCount =
- link.badgeKey === "jobs"
- ? activeJobs
- : link.badgeKey === "running"
- ? runningJobs
- : 0;
- const badgeNoun =
- link.badgeKey === "running" ? "running job" : "active job";
- const showBadge =
- (link.badgeKey === "jobs" || link.badgeKey === "running") &&
- badgeCount > 0;
- return (
- <Link
- key={link.href}
- href={link.href}
- className={navItemClass}
- >
- <span>{link.label}</span>
- {showBadge && (
- <span
- aria-label={`${badgeCount} ${badgeNoun}${badgeCount === 1 ? "" : "s"}`}
- className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none"
- >
- {badgeCount}
- </span>
- )}
- </Link>
- );
- })}
+ {NAV_GROUPS.map((group) => (
+ <div
+ key={group.label}
+ className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch"
+ >
+ <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap">
+ {group.label}
+ </div>
+ {group.links.map(renderLink)}
+ </div>
+ ))}
</nav>
</aside>
<main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main>
diff --git a/editor/app/lib/activeSite.ts b/editor/app/lib/activeSite.ts
@@ -0,0 +1,51 @@
+// 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.
+
+// 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).
+export const ALL_SITES = "__all__";
+
+// localStorage key the selector reads/writes.
+export const ACTIVE_SITE_KEY = "activeSite";
+
+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.
+ value: string;
+ // True when the selection spans every site (full pool).
+ isAll: boolean;
+ // The single active siteId, or null under "all sites".
+ siteId: string | null;
+};
+
+// Resolve the raw `searchParams.site` value 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
+export function resolveActiveSite(
+ paramValue: string | undefined | null,
+ siteIds: string[],
+): ResolvedActiveSite {
+ const all: ResolvedActiveSite = {
+ siteIds,
+ value: ALL_SITES,
+ isAll: true,
+ siteId: null,
+ };
+ if (paramValue && siteIds.includes(paramValue)) {
+ return { siteIds, value: paramValue, isAll: false, siteId: paramValue };
+ }
+ if (paramValue === ALL_SITES) {
+ return all;
+ }
+ // Missing or invalid: default to the lone site when there's exactly one.
+ if (siteIds.length === 1) {
+ return { siteIds, value: siteIds[0], isAll: false, siteId: siteIds[0] };
+ }
+ return all;
+}
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -7,7 +7,14 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
+import {
+ getSite,
+ listSiteIds,
+ siteChannelSlugs,
+} from "yt-dlp-transcript-common/lib/site";
import { loadActionableSummary } from "./actionable/lib/loadActionable";
+import { resolveActiveSite } from "./lib/activeSite";
+import type { ActionableRow } from "./actionable/lib/loadActionable";
export const dynamic = "force-dynamic";
@@ -43,10 +50,27 @@ function loadChangelog(): string | null {
}
}
-export default async function Dashboard() {
+export default async function Dashboard({
+ searchParams,
+}: {
+ searchParams: Promise<{ site?: string }>;
+}) {
const paths = getPaths();
+ const { site } = await searchParams;
+ const active = resolveActiveSite(site, listSiteIds(paths));
const summary = await loadActionableSummary(paths);
- const channels = summary.rows.map((r) => r.channel);
+
+ // Scope the channel-derived stats to the active site's membership. Under "all
+ // sites" the full pool is shown. Job stats stay global (pool state).
+ const inScope = (() => {
+ if (active.isAll || !active.siteId) return () => true;
+ const slugs = siteChannelSlugs(getSite(active.siteId, paths));
+ return (r: ActionableRow) => slugs.has(r.channel.slug);
+ })();
+ const rows = summary.rows.filter(inScope);
+ const undownloaded = summary.undownloaded.filter(inScope);
+ const untranscribed = summary.untranscribed.filter(inScope);
+ const channels = rows.map((r) => r.channel);
const totalVideos = channels.reduce((sum, c) => sum + c.videoCount, 0);
const lastBuild = await getLastBuildTime(paths);
const runningJobs = getRegistry()
@@ -54,7 +78,7 @@ export default async function Dashboard() {
.filter((j) => j.status === "running" || j.status === "queued");
const changelogSource = loadChangelog();
const needsAttention =
- summary.undownloaded.length > 0 || summary.untranscribed.length > 0;
+ undownloaded.length > 0 || untranscribed.length > 0;
return (
<div className="flex flex-col gap-6">
@@ -83,16 +107,14 @@ export default async function Dashboard() {
Needs attention
</h2>
<p className="text-sm">
- {summary.undownloaded.length}{" "}
- {summary.undownloaded.length === 1 ? "channel has" : "channels have"}{" "}
+ {undownloaded.length}{" "}
+ {undownloaded.length === 1 ? "channel has" : "channels have"}{" "}
undownloaded videos
- {summary.untranscribed.length > 0 ? (
+ {untranscribed.length > 0 ? (
<>
{" · "}
- {summary.untranscribed.length}{" "}
- {summary.untranscribed.length === 1
- ? "has videos"
- : "have videos"}{" "}
+ {untranscribed.length}{" "}
+ {untranscribed.length === 1 ? "has videos" : "have videos"}{" "}
awaiting transcription
</>
) : null}
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -71,6 +71,9 @@ test("build static export lives on the deploy page, not the build page", async (
page,
}) => {
await resetData("empty");
+ // A configured site is the active build target; without one the export button
+ // is (correctly) disabled, so add one to keep the enabled check meaningful.
+ await writeSite("testsite", {});
// No longer on the build page.
await page.goto("/build");
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -0,0 +1,115 @@
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, writeSite } from "./helpers";
+
+type SiteFile = { channels: { slug: string }[] };
+
+// Two sites over the shared two-channel pool: alpha exposes slow-a, beta slow-b.
+async function twoSites() {
+ await resetData("two-slow-channels");
+ await writeSite("alpha", {
+ siteTitle: "Alpha",
+ channels: [{ slug: "slow-a" }],
+ });
+ await writeSite("beta", {
+ siteTitle: "Beta",
+ channels: [{ slug: "slow-b" }],
+ });
+}
+
+test("defaults to all sites and shows the full pool", async ({ page }) => {
+ await twoSites();
+ await page.goto("/channels");
+ await expect(page.getByLabel("Active site")).toHaveValue("__all__");
+ await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible();
+});
+
+test("selecting a site scopes the channels list and persists", async ({
+ page,
+}) => {
+ await twoSites();
+ await page.goto("/channels");
+
+ await page.getByLabel("Active site").selectOption("alpha");
+ await expect(page).toHaveURL(/site=alpha/);
+ await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+
+ // localStorage persists the choice: revisiting with no param re-applies it.
+ await page.goto("/channels");
+ await expect(page.getByLabel("Active site")).toHaveValue("alpha");
+ await expect(page).toHaveURL(/site=alpha/);
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+
+ // Switching to beta flips the scope.
+ await page.getByLabel("Active site").selectOption("beta");
+ await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0);
+});
+
+test("dashboard stats and table scope to the active site", async ({ page }) => {
+ await twoSites();
+ await page.goto("/?site=alpha");
+ await expect(page.getByRole("group", { name: "channels" })).toContainText("1");
+ await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+});
+
+test("charts requires a single site", 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 expect(page.getByText(/author the default dashboard/i)).toBeVisible();
+});
+
+test("deploy targets the active site and is disabled under all sites", async ({
+ page,
+}) => {
+ await twoSites();
+ await page.goto("/deploy?site=__all__");
+ await expect(
+ page.getByText(/select a specific site from the sidebar to build/i),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Build static export" }),
+ ).toBeDisabled();
+
+ await page.goto("/deploy?site=alpha");
+ await expect(page.getByText(/Building/).first()).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Build static export" }),
+ ).toBeEnabled();
+});
+
+test("pool views show every site regardless of the selector", async ({
+ page,
+}) => {
+ await twoSites();
+ // Actionable is a shared-pool view: both channels appear even when a single
+ // site is active (slow-a/slow-b have no snapshot → stale-or-missing rows).
+ await page.goto("/actionable?site=alpha");
+ await expect(page.getByLabel("stale-reports row slow-a")).toBeVisible();
+ await expect(page.getByLabel("stale-reports row slow-b")).toBeVisible();
+});
+
+test("creating a channel under a site adds it to that site's membership", async ({
+ page,
+}) => {
+ await twoSites();
+ await page.goto("/channels/new?site=alpha");
+ await page.getByLabel(/^name/i).fill("Gamma Channel");
+ await page.getByLabel(/^slug/i).fill("gamma");
+ await page.getByLabel(/^url/i).fill("https://www.youtube.com/@gamma/videos");
+ await page.getByRole("button", { name: /create channel/i }).click();
+ await page.waitForURL("**/channels/gamma", { timeout: 10_000 });
+
+ const site = await readJson<SiteFile>(
+ "test-transcripts/sites/alpha/site.json",
+ );
+ expect(site.channels.map((c) => c.slug)).toContain("gamma");
+
+ await page.goto("/channels?site=alpha");
+ await expect(page.getByRole("link", { name: "gamma" })).toBeVisible();
+});
diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts
@@ -40,7 +40,9 @@ test("list shows configured sites and edit updates branding", async ({
await writeSite("alpha", { siteTitle: "Alpha Site" });
await page.goto("/sites");
- await expect(page.getByText("Alpha Site")).toBeVisible();
+ // Scope to the sites list link (the site title also appears in the sidebar
+ // site selector now, so a bare getByText would match two elements).
+ await expect(page.getByRole("link", { name: /Alpha Site/ })).toBeVisible();
await page.getByRole("link", { name: /Alpha Site/ }).click();
await expect(page).toHaveURL(/\/sites\/alpha/);