Archilyzer · Source

archilyzer

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

commit 292de396ded34fb74230b48caef0e33126008101
parent 4ea812b3c23899c3148e070363352a9453077734
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 21:05:55 -0400

pages: duplicates windows its list, and touch targets grow

The duplicate clusters were all in the DOM at once; the list is now
windowed against the page scroll, so a 23,000px page is six cards and
400 nodes. Its filter stack folds away below lg, its checkboxes get
rows you can hit with a thumb, and the offline, downloads, hub and post
chrome stop overflowing or hiding controls behind a hover nobody on a
phone can perform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/components/PostModal.tsx | 4++--
Mexport/app/components/OfflineManager.tsx | 8++++----
Mexport/app/components/hub/AddArchive.tsx | 2+-
Mexport/app/components/hub/ArchiveShelf.tsx | 6++++--
Mexport/app/components/hub/HubOfflineManager.tsx | 2+-
Mexport/app/downloads/page.tsx | 2+-
Mexport/app/duplicates/DuplicatesClient.tsx | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
7 files changed, 121 insertions(+), 36 deletions(-)

diff --git a/common/components/PostModal.tsx b/common/components/PostModal.tsx @@ -74,7 +74,7 @@ export default function PostModal() { if (e.target === e.currentTarget) close(); }} > - <div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-lg"> + <div className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-card shadow-lg"> <div className="flex items-center gap-2 border-b border-border px-4 py-2"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> {post.data ? platformLabel(post.data.platform) : "Post"} @@ -84,7 +84,7 @@ export default function PostModal() { variant="ghost" aria-label="Close" onClick={close} - className="ml-auto h-auto px-2 py-1" + className="ml-auto size-11 sm:size-9" > <XIcon className="size-4" /> </Button> diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx @@ -224,7 +224,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { title={ !online ? "Reconnect to build the index" : undefined } - 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 disabled:opacity-50" + className="min-h-9 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 disabled:opacity-50" > {st.index === "building" ? "Indexing…" : "Build index"} </button> @@ -235,7 +235,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { 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" + className="min-h-9 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> @@ -246,7 +246,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { 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" + className="min-h-9 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.download === "downloading" ? "Downloading…" : "Download"} </button> @@ -311,7 +311,7 @@ function OfflineSearch({ onChange={(e) => setTerm(e.target.value)} aria-label="offline search" placeholder="Search indexed transcripts…" - className="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="h-11 rounded-md border border-input bg-background px-3 py-2 text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-9 sm:text-sm" /> </label> {term.trim().length >= 2 && ( diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx @@ -59,7 +59,7 @@ export default function AddArchive() { setState({ status: "idle" }); } }} - className="flex-1 min-w-64 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand" + className="w-full min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-base text-foreground sm:w-auto sm:min-w-64 sm:text-sm placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand" aria-label="Archive URL" /> <button diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx @@ -32,7 +32,7 @@ function Spine({ return ( <li data-testid="shelf-spine" - className="shelf-spine group relative flex min-w-52 max-w-64 flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3" + className="shelf-spine group relative flex w-full min-w-0 max-w-full flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3 sm:w-auto sm:min-w-52 sm:max-w-64" style={{ animationDelay: `${Math.min(index, 8) * 45}ms` }} > {/* The spine: a thick accent edge in the site's own color. */} @@ -50,7 +50,9 @@ function Spine({ type="button" onClick={() => onRemove(site.origin)} aria-label={`Remove ${site.siteTitle}`} - className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" + // Reveal-on-hover is not an affordance on a touch screen: a device + // with no hover shows it always. `hover:` never gates a function. + className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" > <X className="size-3.5" aria-hidden="true" /> </button> diff --git a/export/app/components/hub/HubOfflineManager.tsx b/export/app/components/hub/HubOfflineManager.tsx @@ -111,7 +111,7 @@ export default function HubOfflineManager() { 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" + className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded border border-border/60 px-3 py-1.5 text-sm" > <span className="truncate text-foreground">{c.name}</span> {st.status === "downloading" ? ( diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx @@ -120,7 +120,7 @@ function HoldingRow({ entry }: { entry: ArchiveManifestEntry }) { <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" + className="inline-flex min-h-10 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)} diff --git a/export/app/duplicates/DuplicatesClient.tsx b/export/app/duplicates/DuplicatesClient.tsx @@ -1,7 +1,14 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + useWindowVirtualizer, + type VirtualItem, +} from "yt-dlp-transcript-common/components/virtualizer"; import { usePlayer } from "yt-dlp-transcript-common/components/PlayerProvider"; +import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery"; +import { Checkbox } from "yt-dlp-transcript-common/components/ui/checkbox"; +import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow"; import { fetchTranscript } from "yt-dlp-transcript-common/components/transcriptCache"; import { formatDate } from "yt-dlp-transcript-common/lib/format"; import type { @@ -280,15 +287,7 @@ function DuplicatesView({ No duplicates match the current filters. </p> ) : ( - <ul className="flex flex-col gap-3"> - {visible.map((cluster) => ( - <DuplicateClusterCard - key={cluster.clusterId} - cluster={cluster} - details={details} - /> - ))} - </ul> + <ClusterList clusters={visible} details={details} /> )} </section> ); @@ -329,9 +328,24 @@ function FilterPanel({ onToggleChannel: (c: string) => void; }) { const platformLabel = (n: number) => `platform${n === 1 ? "" : "s"}`; + // Five filter rows above the clusters is most of a phone screen. Open where + // there is room, closed where there isn't, and whatever the reader last chose + // once they choose. + const wide = useMediaQuery("(min-width: 1024px)"); + const [userOpen, setUserOpen] = useState<boolean | null>(null); + const narrowed = + (query.trim() !== "" ? 1 : 0) + + (platforms.size !== presentPlatforms.length ? 1 : 0) + + (matchKinds.size !== MATCH_KINDS.length ? 1 : 0) + + (relationships.size > 0 ? 1 : 0) + + (channels.size > 0 ? 1 : 0); return ( - <details open className="text-sm text-muted-foreground"> - <summary className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <details + open={userOpen ?? wide} + onToggle={(e) => setUserOpen((e.currentTarget as HTMLDetailsElement).open)} + className="text-sm text-muted-foreground" + > + <summary className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 py-1"> <span className="text-xs uppercase tracking-wide text-muted-foreground"> Filters </span> @@ -339,6 +353,11 @@ function FilterPanel({ {platforms.size} of {presentPlatforms.length}{" "} {platformLabel(presentPlatforms.length)} </span> + {narrowed > 0 && ( + <span className="text-xs text-muted-foreground"> + · {narrowed} narrowed + </span> + )} </summary> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2"> <label className="flex items-center gap-1.5 select-none"> @@ -351,7 +370,7 @@ function FilterPanel({ placeholder="title or channel…" value={query} onChange={(e) => onQuery(e.target.value)} - className="rounded-md border border-border bg-card px-2 py-1 text-sm" + className="h-11 rounded-md border border-border bg-card px-2 py-1 text-base sm:h-8 sm:text-sm" /> </label> <FilterRow label="Platforms"> @@ -432,13 +451,9 @@ function Check({ children: React.ReactNode; }) { return ( - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={checked} - onChange={onChange} - className="accent-brand" - /> + // py-1.5 so the whole row is a tap target, not just the 16px box. + <label className="flex cursor-pointer items-center gap-1.5 py-1.5 select-none"> + <Checkbox checked={checked} onCheckedChange={onChange} /> {children} </label> ); @@ -456,7 +471,7 @@ function DuplicateClusterCard({ details: DetailMap; }) { return ( - <li + <article aria-label={`duplicate cluster ${cluster.clusterId}`} className="border border-border rounded-lg overflow-hidden bg-card" > @@ -476,7 +491,75 @@ function DuplicateClusterCard({ <MemberRow key={ref.slug} member={ref} detail={details.get(ref.slug)} /> ))} </ul> - </li> + </article> + ); +} + +// The cluster list, windowed against the page scroll — a site with a real +// duplicate report renders tens of thousands of pixels of cards, and all of it +// used to be in the DOM at once. Same shape as SearchResults' list: a spacer +// div of the full height, absolutely-positioned rows, and a `scrollMargin` that +// tracks where the list starts on the page. +const CLUSTER_ESTIMATE_PX = 140; +const CLUSTER_GAP_PX = 12; + +function ClusterList({ + clusters, + details, +}: { + clusters: DuplicateCluster[]; + details: DetailMap; +}) { + const listRef = useRef<HTMLUListElement | null>(null); + const [scrollMargin, setScrollMargin] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") return; + const update = () => { + const el = listRef.current; + if (!el) return; + setScrollMargin(el.getBoundingClientRect().top + window.scrollY); + }; + update(); + const ro = new ResizeObserver(update); + if (listRef.current) ro.observe(listRef.current); + ro.observe(document.body); + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); + + const virtualizer = useWindowVirtualizer({ + count: clusters.length, + estimateSize: () => CLUSTER_ESTIMATE_PX + CLUSTER_GAP_PX, + overscan: 4, + scrollMargin, + }); + + return ( + <ul + ref={listRef} + className="relative" + style={{ height: virtualizer.getTotalSize() }} + > + {virtualizer.getVirtualItems().map((vi: VirtualItem) => { + const cluster = clusters[vi.index]; + return ( + <VirtualRow + as="li" + key={cluster.clusterId} + measureRef={virtualizer.measureElement} + index={vi.index} + offset={vi.start - scrollMargin} + className="pb-3" + > + <DuplicateClusterCard cluster={cluster} details={details} /> + </VirtualRow> + ); + })} + </ul> ); } @@ -494,15 +577,15 @@ function MemberRow({ const title = detail?.title || member.title || member.slug; return ( <li className="flex flex-col gap-0.5 px-4 py-2 hover:bg-muted"> - <div className="flex items-baseline gap-2"> + <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <button type="button" onClick={() => openTranscript(member.slug)} - className="font-medium truncate flex-1 min-w-0 text-left hover:underline" + className="font-medium line-clamp-2 sm:truncate flex-1 min-w-0 text-left hover:underline" > {title} </button> - <span className="text-xs text-muted-foreground shrink-0"> + <span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0"> {member.channel || member.channelSlug} · {member.platform} ·{" "} {formatDate(member.uploadDate)} </span>