Archilyzer · Source

archilyzer

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

commit 789cddce91438fdea0f548ef9d3420dc514a9c70
parent 15feba3bbfffefe286e889ba97018505bf15db27
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  7 Jul 2026 00:09:54 -0400

Redesign /downloads as per-channel catalog cards

Group each channel's transcript and live-chat bundles into one "record"
card headed by the channel name and its total video count and size,
instead of separately-bordered sibling rows. Move the file size into a
padded download button (fixing the badge that butted the card edge) and
drop the per-row left-accent borders that fought the container. Bundles
show item count + filename as a mono call number; oversize-without-host
bundles still read as unavailable. Uses only semantic theme tokens, so
it adapts across all four families and light/dark.

Add a reduced-motion-safe .record-rise load stagger; release export 0.7.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mexport/CHANGELOG.md | 3+++
Mexport/app/downloads/page.tsx | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mexport/app/globals.css | 21+++++++++++++++++++++
3 files changed, 180 insertions(+), 58 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [0.7.2] - 2026-07-07 +- **The Downloads page was redesigned around channels.** A channel's transcript bundle and its live-chat bundle now sit together in one card — a catalog "record" headed by the channel name and its total video count and size — instead of scattered, separately-bordered rows. Each bundle shows its item count and filename, and the size moved into a padded download button (fixing the badge that used to butt against the card edge). The nested/mismatched borders are gone, the whole page adapts to every theme, and an oversize bundle still reads as unavailable with its reason. + ## [0.7.1] - 2026-07-06 - **The "Ask AI" chat now finds the right videos.** The `/ask` retrieval previously skimmed the first few transcript shards and cut off early, so it kept answering from the same handful of videos no matter what you asked. It now uses the **site's own transcript search engine** (the one behind the search box): your question's keywords rank matches across the whole archive, snippets carry real timestamps, and it reuses the cached index — so different questions surface different, relevant videos, and on the federated hub it searches every member site. - **Search now suggests a better query when you type a known term.** When a search term matches a curated alias — e.g. typing `loli`, `lolly`, or `loly` — a quiet chip appears under the box offering a more robust regex like `\blol(i|ly)`. Click **Apply** to swap it in (and switch that layer to regex mode) or **Dismiss** to ignore it; a new term re-offers. It's never forced, matching is whole-word (so `lolight` won't trigger it), and it's suppressed while you're already writing a regex. The dictionary is authored in the editor's new Search aliases page and shipped per site as `/search-aliases.json` (the global list merged with per-site overrides). See `common/components/QueryLeafView.tsx`, `common/components/{aliasesCache,SearchDataContext}.tsx`, `common/lib/searchAliases.ts`, and `export/e2e/alias-suggestion.spec.ts`. diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import type { ArchiveManifestEntry } from "yt-dlp-transcript-common/lib/archiveOptions"; +import { ArrowDownToLine, FileText, MessagesSquare } from "lucide-react"; import { currentSite } from "../lib/site"; import { readArchiveManifest } from "../lib/archives"; @@ -23,51 +24,153 @@ function countLabel(kind: ArchiveManifestEntry["kind"], n: number): string { return `${n.toLocaleString()} ${noun}${n === 1 ? "" : "s"}`; } -// One archive rendered as a manifest line: a printed contents label on the left, -// the download action on the right. An oversize entry with no overflow URL was -// not shipped, so it reads as unavailable with the reason rather than a dead -// link; everything else links to its local /archives/ file or its overflow URL. -function ArchiveLine({ entry }: { entry: ArchiveManifestEntry }) { - const label = entry.channelTitle ?? (entry.scope === "all" ? "Everything" : entry.scope); +// A scope slug with no channelTitle (e.g. "rekietalaw-rumble") is humanized into +// a readable heading rather than shown as a raw slug. +function humanizeScope(scope: string): string { + return scope + .split(/[-_]/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +// One channel's holdings: the manifest carries transcripts and live chat as +// separate entries that share a scope, so the page groups them back into a +// single record. Counts/bytes sum only the AVAILABLE holdings (an oversize +// bundle with no overflow URL isn't downloadable, so it shouldn't inflate the +// "grab the whole channel" figures). +type ChannelGroup = { + scope: string; + title: string; + entries: ArchiveManifestEntry[]; + videoCount: number; + bytes: number; + availableCount: number; +}; + +const KIND_ORDER: Record<ArchiveManifestEntry["kind"], number> = { + transcripts: 0, + "live-chat": 1, +}; + +function isAvailable(e: ArchiveManifestEntry): boolean { + return !e.oversize || !!e.url; +} + +function groupByChannel(entries: ArchiveManifestEntry[]): ChannelGroup[] { + const byScope = new Map<string, ChannelGroup>(); + for (const e of entries) { + let group = byScope.get(e.scope); + if (!group) { + group = { + scope: e.scope, + title: "", + entries: [], + videoCount: 0, + bytes: 0, + availableCount: 0, + }; + byScope.set(e.scope, group); + } + group.entries.push(e); + if (!group.title && e.channelTitle) group.title = e.channelTitle; + if (isAvailable(e)) { + group.videoCount += e.videoCount; + group.bytes += e.bytes; + group.availableCount += 1; + } + } + const groups = [...byScope.values()]; + for (const g of groups) { + if (!g.title) { + g.title = g.scope === "all" ? "Everything" : humanizeScope(g.scope); + } + g.entries.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]); + } + return groups; +} + +// One holding within a channel record: a transcript or live-chat bundle. Left is +// the kind (icon + label) over a "call number" subline — the item count and the +// archive filename, in mono. Right is the pull action: a padded download tab +// showing the size, or, for an oversize bundle with no overflow host, the reason +// it isn't served. Both variants share the same row padding so nothing butts the +// card edge. +function HoldingRow({ entry }: { entry: ArchiveManifestEntry }) { + const Icon = entry.kind === "transcripts" ? FileText : MessagesSquare; const kindLabel = entry.kind === "transcripts" ? "Transcripts" : "Live chat"; const href = entry.url ?? `/archives/${entry.filename}`; - if (entry.oversize && !entry.url) { - return ( - <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-l-2 border-border/60 py-3 pl-4 opacity-70"> + return ( + <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 px-5 py-4"> + <div className="flex min-w-0 items-start gap-3"> + <Icon + className="mt-0.5 size-4 shrink-0 text-muted-foreground" + aria-hidden + /> <div className="min-w-0"> - <div className="font-medium text-foreground"> - {label} <span className="text-muted-foreground">· {kindLabel}</span> - </div> - <div className="font-mono text-xs text-muted-foreground"> - {countLabel(entry.kind, entry.videoCount)} · {humanBytes(entry.bytes)} + <div className="font-medium text-foreground">{kindLabel}</div> + <div className="truncate font-mono text-xs text-muted-foreground"> + {countLabel(entry.kind, entry.videoCount)} · {entry.filename} </div> </div> - <span className="font-mono text-xs uppercase tracking-[0.12em] text-warning"> - Too large to host - </span> </div> - ); - } + + {isAvailable(entry) ? ( + <a + href={href} + download + className="inline-flex shrink-0 items-center gap-2 rounded-md bg-brand-soft px-3.5 py-2 font-mono text-sm font-medium text-brand transition-colors hover:bg-brand hover:text-brand-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card" + > + <ArrowDownToLine className="size-4" aria-hidden /> + {humanBytes(entry.bytes)} + </a> + ) : ( + <div className="flex shrink-0 flex-col items-end gap-0.5 text-right"> + <span className="font-mono text-xs font-medium uppercase tracking-[0.12em] text-warning"> + Too large to host + </span> + <span className="font-mono text-xs text-muted-foreground"> + {humanBytes(entry.bytes)} + </span> + </div> + )} + </div> + ); +} + +// A channel rendered as a catalog record: a masthead (title + summed holdings) +// over its bundles, hairline-divided. The load-in stagger is a single quiet +// accent; it yields to prefers-reduced-motion (see globals.css .record-rise). +function ChannelRecord({ + group, + index, +}: { + group: ChannelGroup; + index: number; +}) { + const meta = + group.availableCount > 0 + ? `${group.videoCount.toLocaleString()} video${group.videoCount === 1 ? "" : "s"} · ${humanBytes(group.bytes)}` + : "Unavailable"; return ( - <a - href={href} - download - className="group flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-l-2 border-brand/40 py-3 pl-4 transition-colors hover:border-brand hover:bg-muted/40" + <article + className="record-rise overflow-hidden rounded-lg border border-border bg-card" + style={{ animationDelay: `${index * 60}ms` }} > - <div className="min-w-0"> - <div className="font-medium text-foreground group-hover:text-brand transition-colors"> - {label} <span className="text-muted-foreground">· {kindLabel}</span> - </div> - <div className="font-mono text-xs text-muted-foreground"> - {countLabel(entry.kind, entry.videoCount)} · {entry.filename} - </div> + <header className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-border px-5 py-4"> + <h3 className="font-display text-lg font-semibold leading-tight text-foreground"> + {group.title} + </h3> + <p className="font-mono text-xs text-muted-foreground">{meta}</p> + </header> + <div className="divide-y divide-border"> + {group.entries.map((e) => ( + <HoldingRow key={e.filename} entry={e} /> + ))} </div> - <span className="shrink-0 font-mono text-sm font-medium text-brand"> - ↓ {humanBytes(entry.bytes)} - </span> - </a> + </article> ); } @@ -76,12 +179,6 @@ export default function DownloadsPage() { const manifest = readArchiveManifest(); const entries = manifest?.entries ?? []; - // An entry is available if it ships locally (not oversize) or lives in overflow - // storage (has a url). Oversize-without-url entries are unavailable. - const available = entries.filter((e) => !e.oversize || e.url); - const servedCount = available.length; - const totalBytes = available.reduce((n, e) => n + e.bytes, 0); - if (entries.length === 0) { return ( <div className="mx-auto flex max-w-2xl flex-col gap-3"> @@ -97,42 +194,43 @@ export default function DownloadsPage() { ); } + const groups = groupByChannel(entries); + const available = entries.filter(isAvailable); + const channelCount = groups.filter((g) => g.availableCount > 0).length; + const bundleCount = available.length; + const totalBytes = available.reduce((n, e) => n + e.bytes, 0); + return ( - <div className="mx-auto flex max-w-3xl flex-col gap-10"> + <div className="mx-auto flex max-w-3xl flex-col gap-8"> <header className="flex flex-col gap-3 border-b border-border pb-6"> <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand"> Archive · {site.headerTitle} </p> <h1 className="font-display text-3xl font-semibold leading-tight text-foreground"> - Take the whole archive with you + Take the whole channel with you </h1> <p className="max-w-prose text-sm text-muted-foreground"> - Every transcript and live-chat log on this site, packaged per channel - as <code className="font-mono">.zip</code> bundles. Each archive holds - the compact cues JSON per video plus a channel manifest — grab a whole + Every transcript and live-chat log on this site, bundled per channel as{" "} + <code className="font-mono">.zip</code> files. Each bundle holds the + compact cues JSON per video plus a channel manifest — grab a whole channel at once. </p> <p className="font-mono text-xs text-muted-foreground/80"> - {servedCount} bundle{servedCount === 1 ? "" : "s"} available ·{" "} - {humanBytes(totalBytes)} total + {channelCount} channel{channelCount === 1 ? "" : "s"} · {bundleCount}{" "} + bundle{bundleCount === 1 ? "" : "s"} · {humanBytes(totalBytes)} total </p> </header> - <section className="flex flex-col gap-3"> - <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - By channel - </h2> - <div className="flex flex-col divide-y divide-border rounded-lg border border-border bg-card/40"> - {entries.map((e) => ( - <ArchiveLine key={e.filename} entry={e} /> - ))} - </div> + <section className="flex flex-col gap-4"> + {groups.map((g, i) => ( + <ChannelRecord key={g.scope} group={g} index={i} /> + ))} </section> <p className="text-xs text-muted-foreground/70"> Archives refresh on every site build. Transcripts and live chat are - packaged separately; channels with no captured live chat have no live-chat - bundle. + packaged separately; channels with no captured live chat have no + live-chat bundle. </p> </div> ); diff --git a/export/app/globals.css b/export/app/globals.css @@ -34,3 +34,24 @@ body { animation: none; } } + +/* Downloads catalog: each channel record rises into place in sequence (per-card + delay set inline). One quiet accent on the page; yields to reduced-motion. */ +@keyframes record-rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} +.record-rise { + animation: record-rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; +} +@media (prefers-reduced-motion: reduce) { + .record-rise { + animation: none; + } +}