Archilyzer · Source

archilyzer

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

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:
Mexport/app/components/hub/HubHome.tsx | 4++++
Aexport/app/components/hub/HubOfflineManager.tsx | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/hub/HubStats.tsx | 38++++++++++++++++++++++++++++++++++++++
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> + ); +}