commit d961a45282005b0b0c52f6a8147b6513a9c8454b
parent 5735aee7217614ccdbc47a18c0bfd4ded1d31336
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 2 Jul 2026 11:21:12 -0400
Phase 6b: demoted hub stats strip + per-site offline manager
Two remaining hub-frontend consumers, both inside the multi-site provider so
they read the live merged data:
- HubStats: a single demoted mono line (N archives · N channels · N
transcripts) beneath the shelf — the shelf stays the hero, so totals are a
quiet strip, not a KPI wall. Computed from the LIVE federated data (not a
build-time summary), so it always matches what's actually shelved.
- HubOfflineManager: the hub consumer for the Phase-7 origin-aware offline API.
Lists channels grouped by source archive (accent swatch per site) behind a
"Save archives for offline" disclosure; each Save/Remove is scoped to
(origin, slug) via downloadChannelOffline/evictChannelOffline. Kept lean (no
per-channel offline search index) to avoid touching the shipped single-site
OfflineManager + FlexSearch worker.
Screenshot-verified (stats populate, offline disclosure opens with Save
buttons); hub e2e green; site mode untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 189 insertions(+), 0 deletions(-)
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -15,6 +15,8 @@ import {
} from "yt-dlp-transcript-common/components/SearchDataContext";
import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
import ArchiveShelf from "./ArchiveShelf";
+import HubStats from "./HubStats";
+import HubOfflineManager from "./HubOfflineManager";
export default function HubHome() {
const { sites } = useRegistry();
@@ -35,7 +37,9 @@ export default function HubHome() {
<div className="flex flex-col gap-6">
<ArchiveShelf />
<MultiSiteDataProvider sites={federated}>
+ <HubStats />
<TranscriptSearch />
+ <HubOfflineManager />
</MultiSiteDataProvider>
</div>
<TranscriptModal />
diff --git a/export/app/components/hub/HubOfflineManager.tsx b/export/app/components/hub/HubOfflineManager.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+// Hub offline manager: save an archive's channels for offline use, grouped by
+// source site. This is the hub consumer for the origin-aware offline API — each
+// download/evict/status call is scoped to (origin, slug) so channels from
+// different archives never collide. Kept lean (no per-channel offline search
+// index) to stay clear of the shipped single-site OfflineManager.
+
+import { useEffect, useState } from "react";
+import { splitId } from "yt-dlp-transcript-common/components/originId";
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
+import {
+ channelCachedPages,
+ downloadChannelOffline,
+ evictChannelOffline,
+ isPinned,
+ swSupported,
+} from "../../lib/offlineCache";
+
+type DlState = {
+ status: "idle" | "downloading" | "cached" | "error";
+ done: number;
+ total: number;
+};
+const IDLE: DlState = { status: "idle", done: 0, total: 0 };
+
+export default function HubOfflineManager() {
+ const { channels, groups } = useSearchData();
+ const [states, setStates] = useState<Record<string, DlState>>({});
+ const [supported, setSupported] = useState(true);
+
+ const set = (key: string, patch: Partial<DlState>) =>
+ setStates((prev) => ({ ...prev, [key]: { ...(prev[key] ?? IDLE), ...patch } }));
+
+ // Reflect already-pinned/cached channels on mount.
+ useEffect(() => {
+ if (!swSupported()) {
+ setSupported(false);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ for (const c of channels) {
+ const { origin, slug } = splitId(c.key);
+ if (!isPinned(origin, slug)) continue;
+ const cached = await channelCachedPages(origin, slug);
+ if (!cancelled && cached > 0) {
+ set(c.key, { status: "cached", done: cached, total: cached });
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [channels]);
+
+ async function download(key: string) {
+ const { origin, slug } = splitId(key);
+ set(key, { status: "downloading", done: 0, total: 0 });
+ const ok = await downloadChannelOffline(origin, slug, ({ done, total }) =>
+ set(key, { status: "downloading", done, total }),
+ ).catch(() => false);
+ set(key, { status: ok ? "cached" : "error" });
+ }
+
+ async function remove(key: string) {
+ const { origin, slug } = splitId(key);
+ await evictChannelOffline(origin, slug);
+ set(key, { ...IDLE });
+ }
+
+ if (!supported || channels.length === 0) return null;
+
+ // Group channels by their origin (= the group id in hub mode).
+ const byGroup = new Map<string, typeof channels>();
+ for (const c of channels) {
+ const list = byGroup.get(c.groupId) ?? [];
+ list.push(c);
+ byGroup.set(c.groupId, list);
+ }
+
+ return (
+ <details className="rounded-lg border border-border bg-card/40">
+ <summary className="cursor-pointer select-none px-4 py-2 text-sm font-medium text-foreground">
+ Save archives for offline
+ </summary>
+ <div className="flex flex-col gap-4 px-4 pb-4 pt-1">
+ <p className="text-xs text-muted-foreground">
+ Download a channel to read its transcripts without a connection. Saved
+ channels stay on this device until you remove them.
+ </p>
+ {groups
+ .filter((g) => byGroup.has(g.id))
+ .map((g) => (
+ <div key={g.id} className="flex flex-col gap-1.5">
+ <div className="flex items-center gap-2">
+ {g.accent && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: g.accent }}
+ />
+ )}
+ <span className="font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground">
+ {g.name}
+ </span>
+ </div>
+ <ul className="flex flex-col gap-1 list-none">
+ {(byGroup.get(g.id) ?? []).map((c) => {
+ const st = states[c.key] ?? IDLE;
+ return (
+ <li
+ key={c.key}
+ className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-1.5 text-sm"
+ >
+ <span className="truncate text-foreground">{c.name}</span>
+ {st.status === "downloading" ? (
+ <span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
+ {st.total ? `${st.done}/${st.total}` : "…"}
+ </span>
+ ) : st.status === "cached" ? (
+ <button
+ type="button"
+ onClick={() => remove(c.key)}
+ className="shrink-0 rounded border border-success/40 bg-success-soft px-2 py-0.5 text-xs text-success transition-colors hover:bg-success/15"
+ >
+ Saved · Remove
+ </button>
+ ) : (
+ <button
+ type="button"
+ onClick={() => download(c.key)}
+ className="shrink-0 rounded border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs font-medium text-brand transition-colors hover:bg-brand/15"
+ >
+ {st.status === "error" ? "Retry" : "Save"}
+ </button>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </details>
+ );
+}
diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+// A demoted stats line, not a KPI wall: the shelf is the hero, so the totals sit
+// quietly beneath it as a single mono strip. Numbers are computed from the LIVE
+// merged data across the shelved archives (not a build-time summary), so they
+// always reflect exactly what's federated right now.
+
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
+import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
+
+function fmt(n: number): string {
+ return n.toLocaleString();
+}
+
+export default function HubStats() {
+ const { sites } = useRegistry();
+ const { summariesState, channels } = useSearchData();
+ const transcripts = summariesState.manifest?.totalCount ?? 0;
+
+ if (sites.length === 0) return null;
+
+ const parts: [number, string][] = [
+ [sites.length, sites.length === 1 ? "archive" : "archives"],
+ [channels.length, channels.length === 1 ? "channel" : "channels"],
+ [transcripts, transcripts === 1 ? "transcript" : "transcripts"],
+ ];
+
+ return (
+ <p className="font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground">
+ {parts.map(([n, label], i) => (
+ <span key={label}>
+ {i > 0 && <span className="mx-2 text-muted-foreground/40">·</span>}
+ <span className="text-foreground tabular-nums">{fmt(n)}</span> {label}
+ </span>
+ ))}
+ </p>
+ );
+}