Archilyzer · Source

archilyzer

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

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:
Mexport/app/components/hub/HubHome.tsx | 20++++++++++++++++----
Aexport/app/components/hub/HubScope.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/hub/HubStats.tsx | 10+++++++---
Aexport/app/components/hub/useHubScope.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }; +}