Archilyzer · Source

archilyzer

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

commit 53781260c6509097fd6ece02e4397525fedfeab6
parent 8a4c523345a7ed7e0597ac7598b26867345ab9dd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 30 Jun 2026 21:31:42 -0400

Add PWA install + opt-in offline transcript search to export sites

Make each public export site installable and searchable offline, planned around
the existing client-side scanner (FlexSearch, when it lands, reuses the same
cached shards).

- Installable: a per-site web app manifest (export/app/manifest.ts → static
  /manifest.webmanifest under output:export) named + theme-colored from
  currentSite() (accent → theme_color, family brass fallback), plus an Archive
  icon set (192/512/maskable/apple-touch, generated from an SVG). Wired into the
  layout metadata (manifest, icons, appleWebApp, viewport themeColor).

- Service worker (export/public/sw.js), registered in PRODUCTION only (a SW in
  dev fights hot-reload + the e2e request mocks). Strategy: app shell cache-first
  for hashed /_next/static + network-first HTML; transcript/subs/summaries
  manifests network-first, page-NNNN.json shards cache-first. Invalidation is
  keyed off manifest.generatedAt — when a channel's manifest changes, its cached
  page shards are evicted (shard URLs are stable but not content-hashed, so this
  is the only reliable staleness signal). Never precaches the huge corpus.

- Opt-in offline: a new /offline page (footer-linked) lists the site's channels
  (slug+name resolved at build) with a Download control that fetches all of a
  channel's shards into the SW cache via a MessageChannel (with progress), a live
  Online/Offline indicator, per-channel "Available offline" state, and Remove to
  free space. Pinned channels tracked in localStorage. offlineCache.ts is the
  client API; the scanner then searches cached channels with no connection.

Verified: export typecheck clean; a real output:export build emits
/manifest.webmanifest (per-site fields incl. accent theme_color), /sw.js, the
icon set, and the static /offline page; new e2e pwa.spec.ts green (manifest +
icons + SW served, offline page UI); existing browse-all search e2e still green
(SW no longer registers in dev). CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mexport/CHANGELOG.md | 1+
Mexport/app/components/Footer.tsx | 6++++++
Aexport/app/components/OfflineManager.tsx | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/ServiceWorkerRegister.tsx | 29+++++++++++++++++++++++++++++
Mexport/app/layout.tsx | 27+++++++++++++++++++++++++--
Aexport/app/lib/offlineCache.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/manifest.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/offline/page.tsx | 40++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/pwa.spec.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/public/icons/apple-touch-icon.png | 0
Aexport/public/icons/icon-192.png | 0
Aexport/public/icons/icon-512.png | 0
Aexport/public/icons/icon.svg | 6++++++
Aexport/public/icons/maskable-512.png | 0
Aexport/public/icons/maskable.svg | 6++++++
Aexport/public/sw.js | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 751 insertions(+), 2 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Install the archive as an app, and search it offline.** Each public site is now a PWA: it ships a web app manifest (named + themed with the site's accent color) and an icon set, so your browser can install it to your home screen or desktop and open it in its own window. A service worker caches the app so it loads instantly on repeat visits and still opens without a connection. A new **Offline** page (linked in the footer) lists the site's channels with a **Download** button each — pick the channels you want and their transcripts are saved to your device so you can **search them with no connection**; a live Online/Offline indicator and per-channel "Available offline" state show what's ready, and you can remove a channel to free space. Downloads are opt-in (a channel's transcripts can be large) and refresh automatically when the site is rebuilt. Everything still works online-only in browsers without service-worker support. - **Two more themes + a theme picker.** A palette menu next to the light/dark toggle lets you switch the whole site's look between **Archive** (the warm reading room), **Terminal** (an ice-cyan signal terminal), **Swiss** (red/black/white editorial), and **Base** (neutral) — each in light or dark, and your choice is remembered. Switching is instant and purely cosmetic (just colors; the layout never changes). - **Optional per-site brand accent.** A site can set its own accent color (a hex, in the editor's site config) to replace the family brass everywhere on its public build — the wordmark mark, links, badges, and highlights. It's baked onto the page at build time so it's correct on the very first paint, and applies in both light and dark. Sites that don't set one keep the family brass. - **New look: the "Archive / Reading Room" identity.** The public site adopts the family design system — a serif display face (Source Serif 4) for headings/counts, clean sans for body, and mono for the query/power layer, over a warm paper (light) / ink (dark) palette with a brass accent. A light/dark/system theme toggle in the header replaces OS-only dark mode and persists your choice. Header, footer, and the whole search UI (query builder, filters, scopes, charts) are rebuilt on a shared component kit for a calmer, more consistent feel — the simple one-box search is unchanged; the power tools just read better. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -29,6 +29,12 @@ export default function Footer() { > code.tar.gz </a> + <a + href="/offline/" + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + Offline + </a> </div> {socialLinks.length > 0 && ( <ul className="flex items-center gap-3 list-none"> diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + channelCachedPages, + downloadChannelOffline, + evictChannelOffline, + isPinned, + swSupported, +} from "../lib/offlineCache"; + +export type OfflineChannel = { slug: string; name: string }; + +type ChannelState = { + status: "idle" | "downloading" | "cached" | "error"; + done: number; + total: number; +}; + +function useOnline(): boolean { + const [online, setOnline] = useState(true); + useEffect(() => { + const set = () => setOnline(navigator.onLine); + set(); + window.addEventListener("online", set); + window.addEventListener("offline", set); + return () => { + window.removeEventListener("online", set); + window.removeEventListener("offline", set); + }; + }, []); + return online; +} + +export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { + const online = useOnline(); + const [supported, setSupported] = useState(true); + const [states, setStates] = useState<Record<string, ChannelState>>({}); + + const setState = useCallback((slug: string, patch: Partial<ChannelState>) => { + setStates((prev) => { + const base: ChannelState = prev[slug] ?? { + status: "idle", + done: 0, + total: 0, + }; + return { ...prev, [slug]: { ...base, ...patch } }; + }); + }, []); + + // On mount: detect SW support and mark already-cached channels. + useEffect(() => { + if (!swSupported()) { + setSupported(false); + return; + } + let cancelled = false; + (async () => { + for (const c of channels) { + if (!isPinned(c.slug)) continue; + const cached = await channelCachedPages(c.slug); + if (!cancelled && cached > 0) { + setState(c.slug, { status: "cached", done: cached, total: cached }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [channels, setState]); + + async function download(slug: string) { + setState(slug, { status: "downloading", done: 0, total: 0 }); + const ok = await downloadChannelOffline(slug, ({ done, total }) => + setState(slug, { status: "downloading", done, total }), + ).catch(() => false); + setState(slug, ok ? { status: "cached" } : { status: "error" }); + } + + async function remove(slug: string) { + await evictChannelOffline(slug); + setState(slug, { status: "idle", done: 0, total: 0 }); + } + + if (!supported) { + return ( + <div + role="status" + className="rounded-md border border-border bg-muted px-3 py-2 text-sm text-muted-foreground" + > + Offline downloads aren&apos;t available in this browser (no service + worker support). Search still works while you&apos;re online. + </div> + ); + } + + return ( + <div className="flex flex-col gap-3"> + <div + role="status" + aria-label="connection status" + className="flex items-center gap-2 text-xs font-mono uppercase tracking-[0.14em] text-muted-foreground" + > + <span + aria-hidden="true" + className={ + "inline-block h-2 w-2 rounded-full " + + (online ? "bg-brand" : "bg-destructive") + } + /> + {online ? "Online" : "Offline"} + </div> + + {channels.length === 0 ? ( + <p className="text-sm text-muted-foreground"> + This site has no channels. + </p> + ) : ( + <ul className="flex flex-col divide-y divide-border rounded-lg border border-border"> + {channels.map((c) => { + const st = states[c.slug] ?? { status: "idle", done: 0, total: 0 }; + return ( + <li + key={c.slug} + aria-label={`offline ${c.slug}`} + className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3" + > + <span className="min-w-0 flex-1 truncate font-medium text-foreground"> + {c.name} + </span> + + {st.status === "cached" && ( + <span + aria-label={`${c.slug} available offline`} + className="rounded-full border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs text-brand" + > + Available offline + </span> + )} + {st.status === "error" && ( + <span className="text-xs text-destructive"> + Download failed + </span> + )} + {st.status === "downloading" && ( + <span className="font-mono text-xs tabular-nums text-muted-foreground"> + {st.total > 0 + ? `${Math.round((st.done / st.total) * 100)}%` + : "starting…"} + </span> + )} + + {st.status === "cached" ? ( + <button + type="button" + onClick={() => remove(c.slug)} + aria-label={`remove offline copy of ${c.slug}`} + className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted" + > + Remove + </button> + ) : ( + <button + type="button" + disabled={st.status === "downloading" || !online} + onClick={() => download(c.slug)} + aria-label={`download ${c.slug} for offline`} + title={!online ? "Reconnect to download" : undefined} + className="rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50" + > + {st.status === "downloading" ? "Downloading…" : "Download"} + </button> + )} + </li> + ); + })} + </ul> + )} + </div> + ); +} diff --git a/export/app/components/ServiceWorkerRegister.tsx b/export/app/components/ServiceWorkerRegister.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; + +// Registers the offline service worker (public/sw.js) after load. Rendered once +// in the root layout. No-op where service workers are unavailable (older +// browsers, insecure origins) — the site still works online-only. +export function ServiceWorkerRegister() { + useEffect(() => { + // Only register in production builds. A SW caching assets/data in dev fights + // hot-reload and the e2e suite's request mocking; the deployed static export + // is where offline support matters. + if (process.env.NODE_ENV !== "production") return; + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { + return; + } + const register = () => { + navigator.serviceWorker.register("/sw.js").catch(() => { + // Registration failures are non-fatal (e.g. SW disabled) — stay online. + }); + }; + if (document.readyState === "complete") register(); + else { + window.addEventListener("load", register, { once: true }); + return () => window.removeEventListener("load", register); + } + }, []); + return null; +} diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -1,14 +1,17 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { fontVars } from "yt-dlp-transcript-common/styles/fonts"; import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript"; import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider"; import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; -import { siteAccentVars } from "yt-dlp-transcript-common/lib/accent"; +import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent"; import { currentSite } from "./lib/site"; import Header from "./components/Header"; import Footer from "./components/Footer"; +import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister"; import "./globals.css"; +const FAMILY_BRASS = "#95661a"; + export function generateMetadata(): Metadata { const s = currentSite(); return { @@ -17,9 +20,28 @@ export function generateMetadata(): Metadata { template: `%s — ${s.siteTitle}`, }, description: s.siteDescription, + // Installable PWA: manifest + icon set + iOS standalone hints. + manifest: "/manifest.webmanifest", + icons: { + icon: [ + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], + }, + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: s.headerTitle || s.siteTitle, + }, }; } +export function generateViewport(): Viewport { + // Browser chrome color tracks the site's brand accent (falls back to brass). + return { themeColor: parseAccent(currentSite().accent) ?? FAMILY_BRASS }; +} + export default async function RootLayout({ children, }: Readonly<{ @@ -37,6 +59,7 @@ export default async function RootLayout({ > <body className="min-h-full flex flex-col bg-background text-foreground font-sans"> <ThemeScript defaultTheme="archive" defaultMode="system" /> + <ServiceWorkerRegister /> <ThemeProvider defaultTheme="archive" defaultMode="system"> <QueryProvider> <Header /> diff --git a/export/app/lib/offlineCache.ts b/export/app/lib/offlineCache.ts @@ -0,0 +1,136 @@ +"use client"; + +// Client helpers for the opt-in "download this channel for offline" feature and +// the pinned-channel registry. Talks to the service worker (public/sw.js) over a +// MessageChannel; degrades gracefully where no SW is controlling the page. + +import { + transcriptPageFileName, + type ChannelTranscriptsManifest, +} from "yt-dlp-transcript-common/lib/manifest"; + +const PINNED_KEY = "ytdlp-tb:offline-channels"; + +export function swSupported(): boolean { + return ( + typeof navigator !== "undefined" && "serviceWorker" in navigator + ); +} + +async function controller(): Promise<ServiceWorker | null> { + if (!swSupported()) return null; + // Wait for an active, controlling worker (first load registers but may not + // control this page until reload; ready resolves once active). + const reg = await navigator.serviceWorker.ready.catch(() => null); + return navigator.serviceWorker.controller ?? reg?.active ?? null; +} + +// Round-trip a message to the SW over a fresh MessageChannel. `onMessage` sees +// every reply; the promise resolves when a reply of type `done`/`ok`/`status` +// arrives (or rejects on timeout). +function sendToSw( + sw: ServiceWorker, + message: unknown, + onMessage?: (data: any) => void, +): Promise<any> { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timer = setTimeout(() => reject(new Error("SW timeout")), 10 * 60_000); + channel.port1.onmessage = (e) => { + const data = e.data || {}; + onMessage?.(data); + if (data.type === "done" || data.ok || data.type === "status") { + clearTimeout(timer); + resolve(data); + } + }; + sw.postMessage(message, [channel.port2]); + }); +} + +async function fetchManifest( + slug: string, +): Promise<ChannelTranscriptsManifest | null> { + try { + const res = await fetch(`/transcripts/${slug}/manifest.json`, { + cache: "no-store", + }); + if (!res.ok) return null; + return (await res.json()) as ChannelTranscriptsManifest; + } catch { + return null; + } +} + +export type DownloadProgress = { done: number; total: number }; + +// Download every shard of a channel into the SW cache, reporting progress. The +// URL list is the channel manifest plus each page-NNNN.json. +export async function downloadChannelOffline( + slug: string, + onProgress?: (p: DownloadProgress) => void, +): Promise<boolean> { + const sw = await controller(); + if (!sw) return false; + const manifest = await fetchManifest(slug); + if (!manifest) return false; + const urls = [`/transcripts/${slug}/manifest.json`]; + for (let p = 0; p < manifest.pageCount; p++) { + urls.push(`/transcripts/${slug}/${transcriptPageFileName(p)}`); + } + await sendToSw(sw, { type: "CACHE_URLS", slug, urls }, (data) => { + if (data.type === "progress") { + onProgress?.({ done: data.done, total: data.total }); + } + }); + pin(slug); + return true; +} + +export async function evictChannelOffline(slug: string): Promise<void> { + const sw = await controller(); + if (sw) await sendToSw(sw, { type: "EVICT_CHANNEL", slug }).catch(() => {}); + unpin(slug); +} + +// How many page shards of a channel are currently cached (0 = not offline). +export async function channelCachedPages(slug: string): Promise<number> { + const sw = await controller(); + if (!sw) return 0; + try { + const data = await sendToSw(sw, { type: "CHANNEL_STATUS", slug }); + return typeof data.cachedPages === "number" ? data.cachedPages : 0; + } catch { + return 0; + } +} + +// ---- Pinned registry (which channels the user chose to keep offline) ---- +export function pinnedChannels(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(PINNED_KEY); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : []; + } catch { + return []; + } +} +export function isPinned(slug: string): boolean { + return pinnedChannels().includes(slug); +} +function writePinned(slugs: string[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(PINNED_KEY, JSON.stringify([...new Set(slugs)])); + } catch { + /* quota — non-fatal */ + } +} +function pin(slug: string): void { + writePinned([...pinnedChannels(), slug]); +} +function unpin(slug: string): void { + writePinned(pinnedChannels().filter((s) => s !== slug)); +} diff --git a/export/app/manifest.ts b/export/app/manifest.ts @@ -0,0 +1,50 @@ +import type { MetadataRoute } from "next"; +import { parseAccent } from "yt-dlp-transcript-common/lib/accent"; +import { currentSite } from "./lib/site"; + +// Web App Manifest → statically emitted as /manifest.webmanifest at build time +// (output: "export"). Per-site: name/description from currentSite(), theme_color +// from the site's brand accent (falls back to the family brass). Makes each +// deployed export site installable as a standalone PWA. Icons are the shared +// Archive mark under public/icons (a per-site accent-tinted icon is a later +// refinement — theme_color already differentiates the browser chrome). +export const dynamic = "force-static"; + +// Family brass — the archive brand accent, used when a site sets no accent. +const FAMILY_BRASS = "#95661a"; + +export default function manifest(): MetadataRoute.Manifest { + const site = currentSite(); + const themeColor = parseAccent(site.accent) ?? FAMILY_BRASS; + return { + name: site.siteTitle, + short_name: site.headerTitle || site.siteTitle, + description: site.siteDescription, + start_url: "/", + scope: "/", + display: "standalone", + orientation: "portrait-primary", + background_color: "#0c0a08", + theme_color: themeColor, + icons: [ + { + src: "/icons/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/maskable-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/export/app/offline/page.tsx b/export/app/offline/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { currentSite } from "../lib/site"; +import { OfflineManager, type OfflineChannel } from "../components/OfflineManager"; + +export const metadata: Metadata = { + title: "Offline", + description: "Download channels for offline search.", +}; + +// Static list of this site's channels (slug + display name), resolved at build +// time. The client OfflineManager uses the slugs to fetch/cache each channel's +// transcript shards through the service worker. +export default async function OfflinePage() { + const site = currentSite(); + const paths = getPaths(); + const channels: OfflineChannel[] = await Promise.all( + site.channels.map(async ({ slug }) => { + const config = await readChannelConfig(paths, slug).catch(() => null); + return { slug, name: config?.name ?? slug }; + }), + ); + channels.sort((a, b) => a.name.localeCompare(b.name)); + + return ( + <div className="flex flex-col gap-6"> + <div className="flex flex-col gap-1"> + <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground"> + Offline + </h1> + <p className="text-sm text-muted-foreground"> + Install this archive and download channels to search them without a + connection. Downloads can be large — each channel is fetched on demand. + </p> + </div> + <OfflineManager channels={channels} /> + </div> + ); +} diff --git a/export/e2e/pwa.spec.ts b/export/e2e/pwa.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +// PWA surface for the export (Archive) sites: an installable web app manifest, +// a registered service worker, and the /offline management page. The transcript +// shard caching itself is validated by the production build (artifacts emit) + +// the SW code; the e2e harness mocks /transcripts/** so a full offline +// round-trip isn't exercised here. + +test("serves a valid web app manifest with per-site fields", async ({ + request, +}) => { + const res = await request.get("/manifest.webmanifest"); + expect(res.ok()).toBeTruthy(); + const m = await res.json(); + expect(m.name).toBeTruthy(); + expect(m.start_url).toBe("/"); + expect(m.display).toBe("standalone"); + expect(Array.isArray(m.icons)).toBe(true); + // 192 + 512 + a maskable icon (installability). + const sizes = m.icons.map((i: { sizes: string }) => i.sizes); + expect(sizes).toContain("192x192"); + expect(sizes).toContain("512x512"); + expect( + m.icons.some((i: { purpose?: string }) => i.purpose === "maskable"), + ).toBe(true); + // theme_color is a hex string (from the site accent or the family brass). + expect(m.theme_color).toMatch(/^#[0-9a-f]{6}$/i); +}); + +test("serves the service worker and the icon set", async ({ request }) => { + const sw = await request.get("/sw.js"); + expect(sw.ok()).toBeTruthy(); + for (const icon of [ + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/maskable-512.png", + "/icons/apple-touch-icon.png", + ]) { + const r = await request.get(icon); + expect(r.ok(), icon).toBeTruthy(); + } +}); + +// Note: the service worker registers in PRODUCTION builds only (it caches +// assets/data and would fight dev hot-reload + this suite's request mocking), so +// live registration isn't asserted here — the production build proves /sw.js, +// the manifest, and the icons emit into out/. See ServiceWorkerRegister.tsx. + +test("the offline page lists channels with a connection status and download control", async ({ + page, +}) => { + await page.goto("/offline/"); + await expect( + page.getByRole("heading", { name: "Offline", level: 1 }), + ).toBeVisible(); + await expect(page.getByLabel("connection status")).toBeVisible(); + // testsite exposes at least one channel → a Download control is present. + await expect( + page.getByRole("button", { name: /download .* for offline/ }).first(), + ).toBeVisible(); +}); diff --git a/export/public/icons/apple-touch-icon.png b/export/public/icons/apple-touch-icon.png Binary files differ. diff --git a/export/public/icons/icon-192.png b/export/public/icons/icon-192.png Binary files differ. diff --git a/export/public/icons/icon-512.png b/export/public/icons/icon-512.png Binary files differ. diff --git a/export/public/icons/icon.svg b/export/public/icons/icon.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> + <rect width="512" height="512" rx="112" fill="#0c0a08"/> + <polygon points="256,92 420,256 256,420 92,256" fill="#e3b15c"/> + <polygon points="256,150 362,256 256,362 150,256" fill="#0c0a08"/> + <polygon points="256,196 316,256 256,316 196,256" fill="#e3b15c"/> +</svg> diff --git a/export/public/icons/maskable-512.png b/export/public/icons/maskable-512.png Binary files differ. diff --git a/export/public/icons/maskable.svg b/export/public/icons/maskable.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> + <rect width="512" height="512" fill="#0c0a08"/> + <polygon points="256,140 372,256 256,372 140,256" fill="#e3b15c"/> + <polygon points="256,182 330,256 256,330 182,256" fill="#0c0a08"/> + <polygon points="256,214 298,256 256,298 214,256" fill="#e3b15c"/> +</svg> diff --git a/export/public/sw.js b/export/public/sw.js @@ -0,0 +1,210 @@ +/* Service worker for the export (Archive) search sites. + * + * Offline model (see plan): browse-cache + opt-in per-channel download. Nothing + * huge is precached on install — the transcript corpus is hundreds of MB. We + * runtime-cache the app shell + whatever the user actually opens, and expose a + * message API so the page can bulk-download a channel's shards for offline use. + * + * Caches: + * SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML + * navigations (network-first, cache fallback) + icons/manifest. + * PAGES — transcript/subs/summaries JSON shards (manifest.json + page-NNNN.json). + * Pages are cache-first; manifests are network-first so a rebuild is + * seen. Invalidation is keyed off manifest.generatedAt: when a channel's + * manifest generatedAt changes, that channel's cached page shards are + * evicted (the shards have stable, non-content-hashed URLs, so this is + * the only reliable staleness signal). + * META — tiny synthetic Responses storing each channel's last-seen generatedAt + * (avoids IndexedDB inside the SW). + */ + +const VERSION = "v1"; +const SHELL = `shell-${VERSION}`; +const PAGES = `pages-${VERSION}`; +const META = `meta-${VERSION}`; + +// Matches /transcripts/<slug>/... and the parallel /subs, /summaries trees. +const SHARD_RE = /^\/(transcripts|subs|summaries)\/([^/]+)\/(.+)$/; + +self.addEventListener("install", () => { + // Activate immediately — no precache list (corpus is too large to bundle). + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // Drop caches from older SW versions. + const keep = new Set([SHELL, PAGES, META]); + const names = await caches.keys(); + await Promise.all(names.map((n) => (keep.has(n) ? null : caches.delete(n)))); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + const shard = SHARD_RE.exec(url.pathname); + if (shard) { + const [, , slug, rest] = shard; + if (rest === "manifest.json") { + event.respondWith(handleManifest(req, slug)); + } else { + event.respondWith(cacheFirst(req, PAGES)); + } + return; + } + + // App shell. + if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + event.respondWith(cacheFirst(req, SHELL)); + return; + } + if (req.mode === "navigate" || req.destination === "document") { + event.respondWith(networkFirstDoc(req)); + return; + } +}); + +// Cache-first: serve from cache, else fetch and store. +async function cacheFirst(req, cacheName) { + const cache = await caches.open(cacheName); + const hit = await cache.match(req); + if (hit) return hit; + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + // Offline miss — nothing cached. + return hit || Response.error(); + } +} + +// Navigations: network-first (fresh HTML after deploys), fall back to cache, then +// to any cached document so the installed app still opens offline. +async function networkFirstDoc(req) { + const cache = await caches.open(SHELL); + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + const hit = await cache.match(req); + if (hit) return hit; + const root = await cache.match("/"); + if (root) return root; + return Response.error(); + } +} + +// Channel manifest: network-first. On a fresh manifest, compare generatedAt to +// the stored value and evict that channel's page shards if it changed. Offline → +// serve the cached manifest. +async function handleManifest(req, slug) { + const pages = await caches.open(PAGES); + try { + const res = await fetch(req); + if (res.ok) { + const clone = res.clone(); + try { + const body = await clone.json(); + const gen = String(body?.generatedAt ?? ""); + const prev = await readMeta(slug); + if (prev !== null && prev !== gen) { + await evictChannelPages(slug); + } + await writeMeta(slug, gen); + } catch { + // Unparseable manifest — still serve it. + } + pages.put(req, res.clone()); + } + return res; + } catch (err) { + const hit = await pages.match(req); + return hit || Response.error(); + } +} + +async function evictChannelPages(slug) { + const pages = await caches.open(PAGES); + const keys = await pages.keys(); + const prefixes = [ + `/transcripts/${slug}/`, + `/subs/${slug}/`, + `/summaries/${slug}/`, + ]; + await Promise.all( + keys.map((k) => { + const p = new URL(k.url).pathname; + const inChannel = prefixes.some( + (pre) => p.startsWith(pre) && !p.endsWith("manifest.json"), + ); + return inChannel ? pages.delete(k) : null; + }), + ); +} + +// Per-channel generatedAt stored as a synthetic Response in the META cache. +async function readMeta(slug) { + const meta = await caches.open(META); + const hit = await meta.match(`/__gen__/${slug}`); + return hit ? await hit.text() : null; +} +async function writeMeta(slug, gen) { + const meta = await caches.open(META); + await meta.put(`/__gen__/${slug}`, new Response(gen)); +} + +// ---- Message API (the page drives bulk download / eviction / status) ---- +self.addEventListener("message", (event) => { + const data = event.data || {}; + const port = event.ports && event.ports[0]; + if (data.type === "CACHE_URLS") { + event.waitUntil(cacheUrls(data.slug, data.urls || [], port)); + } else if (data.type === "EVICT_CHANNEL") { + event.waitUntil( + evictChannelPages(data.slug).then(() => port && port.postMessage({ ok: true })), + ); + } else if (data.type === "CHANNEL_STATUS") { + event.waitUntil(channelStatus(data.slug, port)); + } +}); + +// Fetch + cache a list of shard URLs, reporting progress over the message port. +async function cacheUrls(slug, urls, port) { + const cache = await caches.open(PAGES); + let done = 0; + for (const u of urls) { + try { + const res = await fetch(u, { cache: "reload" }); + if (res.ok) await cache.put(u, res.clone()); + } catch { + // Skip failures; the channel just won't be fully offline. + } + done++; + if (port) port.postMessage({ type: "progress", slug, done, total: urls.length }); + } + if (port) port.postMessage({ type: "done", slug, done, total: urls.length }); +} + +// Report how many of a channel's page shards are currently cached. +async function channelStatus(slug, port) { + const cache = await caches.open(PAGES); + const keys = await cache.keys(); + const prefix = `/transcripts/${slug}/`; + let cached = 0; + for (const k of keys) { + const p = new URL(k.url).pathname; + if (p.startsWith(prefix) && p.endsWith(".json") && !p.endsWith("manifest.json")) { + cached++; + } + } + if (port) port.postMessage({ type: "status", slug, cachedPages: cached }); +}