commit 9a3f4bb88574528cd569fc2147e754f0ce86d9ef
parent d8de0fd14841b55a8012744cd7cd9e0357334e21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:16:35 -0400
hub: scope chips — one per archive, toggles it in or out of the search, remembered per browser
A row above the query builder (HubScope): each chip shows its archive's state
(loading, its transcript count once ready, failed with a Retry). Scope is kept
as the set of origins switched OFF (localStorage ytdlp-tb:hub-scope, guarded),
so an archive this browser has never seen is searched. HubHome passes it to the
provider as enabled, and asks for progressive readiness; HubStats counts the
archives in scope.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 187 insertions(+), 7 deletions(-)
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -19,6 +19,8 @@ import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
import ArchiveShelf from "./ArchiveShelf";
import HubStats from "./HubStats";
import HubOfflineManager from "./HubOfflineManager";
+import HubScope from "./HubScope";
+import { useHubScope } from "./useHubScope";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
// component cannot read site.json): false hides the modal's per-video export
@@ -29,24 +31,34 @@ export default function HubHome({
transcriptDownloads?: boolean;
}) {
const { sites } = useRegistry();
+ // Which archives the search covers (every one unless this browser switched
+ // it off with its chip).
+ const { isOn, toggle } = useHubScope();
- // Carry each site's accent through to the merged search for provenance.
+ // Carry each site's accent through to the merged search for provenance, and
+ // its scope: an archive switched off is not fetched at all.
const federated = useMemo<FederatedSite[]>(
() =>
sites.map((s) => ({
origin: s.origin,
siteTitle: s.siteTitle,
accent: s.accent,
+ enabled: isOn(s.origin),
})),
- [sites],
+ [sites, isOn],
);
return (
<PlayerProvider features={{ transcriptDownloads }}>
<div className="flex flex-col gap-6">
<ArchiveShelf />
- <MultiSiteDataProvider sites={federated}>
- <HubStats />
+ {/* progressive: the search runs as soon as one archive is in, and
+ re-runs as each further archive arrives. */}
+ <MultiSiteDataProvider sites={federated} progressive>
+ <div className="flex flex-col gap-3">
+ <HubStats />
+ <HubScope onToggle={toggle} />
+ </div>
<TranscriptSearch />
<HubOfflineManager />
</MultiSiteDataProvider>
diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+// The row of scope chips above the query builder: one per archive on the hub,
+// official and added. Pressing a chip takes that archive in or out of the
+// search (useHubScope remembers it per browser). Each chip says what its
+// archive is doing — loading, its transcript count once ready, or failed with
+// a Retry beside it — so a slow or dead member is visible, not a silent gap.
+
+import { cn } from "yt-dlp-transcript-common/lib/utils";
+import {
+ useSearchData,
+ type FederatedSiteState,
+} from "yt-dlp-transcript-common/components/SearchDataContext";
+
+function detail(s: FederatedSiteState): string {
+ switch (s.status) {
+ case "off":
+ return "off";
+ case "loading":
+ return "loading…";
+ case "failed":
+ return "failed";
+ case "ready":
+ return s.count === undefined ? "ready" : s.count.toLocaleString();
+ }
+}
+
+export default function HubScope({
+ onToggle,
+}: {
+ onToggle: (origin: string) => void;
+}) {
+ const { federation } = useSearchData();
+ if (!federation || federation.sites.length === 0) return null;
+ const { sites, retry } = federation;
+
+ return (
+ <div
+ role="group"
+ aria-label="Archives to search"
+ className="flex flex-wrap items-center gap-2"
+ >
+ {sites.map((s) => {
+ const on = s.status !== "off";
+ return (
+ <span
+ key={s.origin}
+ data-testid={`hub-scope-chip-${s.origin}`}
+ data-status={s.status}
+ className={cn(
+ "inline-flex min-w-0 items-stretch overflow-hidden rounded border text-sm transition-colors",
+ on ? "border-border-strong bg-card" : "border-border bg-transparent",
+ s.status === "failed" && "border-destructive/60",
+ )}
+ >
+ <button
+ type="button"
+ aria-pressed={on}
+ title={on ? `Stop searching ${s.siteTitle}` : `Search ${s.siteTitle}`}
+ onClick={() => onToggle(s.origin)}
+ className={cn(
+ "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground",
+ on ? "text-foreground" : "text-muted-foreground",
+ )}
+ >
+ {s.accent && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: s.accent }}
+ />
+ )}
+ <span className="truncate">{s.siteTitle}</span>
+ <span
+ className={cn(
+ "shrink-0 text-xs tabular-nums",
+ s.status === "failed" ? "text-destructive" : "text-muted-foreground",
+ )}
+ >
+ {detail(s)}
+ </span>
+ </button>
+ {s.status === "failed" && (
+ <button
+ type="button"
+ onClick={() => retry(s.origin)}
+ aria-label={`Retry ${s.siteTitle}`}
+ title={s.error ? `${s.error} — try again` : "Try again"}
+ className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent"
+ >
+ Retry
+ </button>
+ )}
+ </span>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx
@@ -5,7 +5,7 @@
// archive on the hub, the visitor's added ones included — so they are not the
// cards' numbers and are not meant to match them. The official cards show the
// build-time figures the homepage prints (hub-summary.json); this line shows
-// what actually loaded.
+// what actually loaded, from the archives this browser has in scope.
import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
@@ -21,14 +21,18 @@ function part(n: number, one: string, many: string) {
export default function HubStats() {
const { sites } = useRegistry();
- const { summariesState, channels } = useSearchData();
+ const { summariesState, channels, federation } = useSearchData();
+ // The archives in scope (the visitor's chips), and what has arrived from them:
+ // channels as each manifest lands, transcripts as each archive is ready.
+ const archives =
+ federation?.sites.filter((s) => s.status !== "off").length ?? sites.length;
const transcripts = summariesState.manifest?.totalCount ?? 0;
if (sites.length === 0) return null;
return (
<p className="text-sm text-muted-foreground">
- Searching {part(sites.length, "archive", "archives")}
+ Searching {part(archives, "archive", "archives")}
<span className="mx-2 text-muted-foreground/40">·</span>
{part(channels.length, "channel", "channels")}
<span className="mx-2 text-muted-foreground/40">·</span>
diff --git a/export/app/components/hub/useHubScope.ts b/export/app/components/hub/useHubScope.ts
@@ -0,0 +1,65 @@
+"use client";
+
+// Which archives the hub's search covers, per browser. Every archive is in by
+// default; the visitor takes one out with its scope chip. Stored as the set of
+// origins switched OFF, so an archive the browser has never seen (a new
+// official instance, a site added later) is searched until someone says not to.
+// localStorage is a convenience here: when it is unavailable every archive is
+// simply searched.
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const STORAGE_KEY = "ytdlp-tb:hub-scope";
+
+function readOff(): ReadonlySet<string> {
+ if (typeof window === "undefined") return new Set();
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
+ const off =
+ parsed && typeof parsed === "object" && Array.isArray((parsed as { off?: unknown }).off)
+ ? (parsed as { off: unknown[] }).off
+ : [];
+ return new Set(off.filter((o): o is string => typeof o === "string"));
+ } catch {
+ return new Set();
+ }
+}
+
+function writeOff(off: ReadonlySet<string>) {
+ try {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ off: Array.from(off) }),
+ );
+ } catch {
+ // Storage full / disabled — the choice still holds for this visit.
+ }
+}
+
+export function useHubScope() {
+ // Read on the client's first render. The registry's server snapshot is empty,
+ // so nothing that depends on this renders during hydration.
+ const [off, setOff] = useState<ReadonlySet<string>>(readOff);
+ const first = useRef(true);
+ useEffect(() => {
+ if (first.current) {
+ first.current = false;
+ return;
+ }
+ writeOff(off);
+ }, [off]);
+
+ const toggle = useCallback((origin: string) => {
+ setOff((prev) => {
+ const next = new Set(prev);
+ if (next.has(origin)) next.delete(origin);
+ else next.add(origin);
+ return next;
+ });
+ }, []);
+
+ const isOn = useCallback((origin: string) => !off.has(origin), [off]);
+
+ return { isOn, toggle };
+}