Archilyzer · Source

archilyzer

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

commit 4bcd156b67f4f3228bdec05152097c4ad7053fb7
parent 0cc979a56242db8adee6c17ed4e1f588641a5389
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 26 May 2026 12:28:10 -0400

add browse mode to see all videos without a search

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 609+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mexport/CHANGELOG.md | 3++-
Aexport/e2e/browse-all.spec.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 419 insertions(+), 280 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -361,7 +361,11 @@ export default function TranscriptSearch() { if (!c.name) continue; map.set( c.name, - resolveChannelGroupId(c.groupId, manifestGroups, manifestDefaultGroupId), + resolveChannelGroupId( + c.groupId, + manifestGroups, + manifestDefaultGroupId, + ), ); } } @@ -389,7 +393,11 @@ export default function TranscriptSearch() { for (const name of channelOptions) { const gid = channelGroupByName.get(name) ?? - resolveChannelGroupId(undefined, manifestGroups, manifestDefaultGroupId); + resolveChannelGroupId( + undefined, + manifestGroups, + manifestDefaultGroupId, + ); const list = byId.get(gid) ?? []; list.push(name); byId.set(gid, list); @@ -405,7 +413,12 @@ export default function TranscriptSearch() { }); } return out; - }, [channelOptions, channelGroupByName, manifestGroups, manifestDefaultGroupId]); + }, [ + channelOptions, + channelGroupByName, + manifestGroups, + manifestDefaultGroupId, + ]); const defaultSelectedChannels = useMemo<Set<string>>(() => { const out = new Set<string>(); @@ -413,7 +426,11 @@ export default function TranscriptSearch() { for (const name of channelOptions) { const gid = channelGroupByName.get(name) ?? - resolveChannelGroupId(undefined, manifestGroups, manifestDefaultGroupId); + resolveChannelGroupId( + undefined, + manifestGroups, + manifestDefaultGroupId, + ); const g = groupById.get(gid); if (g?.selectedByDefault) out.add(name); } @@ -533,8 +550,27 @@ export default function TranscriptSearch() { // hits collected by searchEval. Hits are already sorted by start time // within each video. const resultGroups = useMemo<ResultGroup[]>(() => { - if (!hasActiveQuery) return []; - if (!transcripts || !treeProgress) return []; + if (!transcripts) return []; + if (!hasActiveQuery) { + // Browse mode: list every video passing the committed filters, in the + // summaries' natural (newest-first) order, with no hits. No transcript + // fetches happen — all the card metadata is already in the summaries. + const out: ResultGroup[] = []; + for (const t of transcripts) { + if (!passesFilter(t)) continue; + out.push({ + slug: t.slug, + title: t.title, + channel: t.channel, + date: t.date, + isLivestream: t.isLivestream, + ageRestricted: t.ageRestricted, + hits: [], + }); + } + return out; + } + if (!treeProgress) return []; const matched = treeProgress.slugs; const hitsBySlug = treeProgress.hits; const out: ResultGroup[] = []; @@ -551,7 +587,7 @@ export default function TranscriptSearch() { }); } return out; - }, [hasActiveQuery, transcripts, treeProgress]); + }, [hasActiveQuery, transcripts, treeProgress, passesFilter]); const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>(); const groupStates = @@ -579,7 +615,10 @@ export default function TranscriptSearch() { }, [treeProgress, totalToProcess]); const indexLoading = - hasActiveQuery && (!summariesReady || (needsChatManifests && !subsManifestReady)); + hasActiveQuery && + (!summariesReady || (needsChatManifests && !subsManifestReady)); + // Browse mode still needs the summaries before it can list anything. + const summariesLoading = !summariesReady; const searching = hasActiveQuery && totalToProcess > 0; const pipelineActive = searching && !pipelineDone; const indexProgress = @@ -749,8 +788,8 @@ export default function TranscriptSearch() { // Fall back to stored profile / working snapshot. const snapshot = stored?.activeProfileName != null - ? stored.profiles[stored.activeProfileName] ?? stored.working - : stored?.working ?? null; + ? (stored.profiles[stored.activeProfileName] ?? stored.working) + : (stored?.working ?? null); const excluded = snapshotToExcluded( snapshot, channelOptions, @@ -799,9 +838,7 @@ export default function TranscriptSearch() { // saves so concurrent writes to other top-level fields aren't clobbered. const persistUiCollapse = useCallback( ( - patch: Partial< - Pick<StoredState, "collapsedGroups" | "filtersCollapsed"> - >, + patch: Partial<Pick<StoredState, "collapsedGroups" | "filtersCollapsed">>, ) => { const current = loadStoredState() ?? emptyStoredState(); if (patch.collapsedGroups !== undefined) { @@ -825,7 +862,10 @@ export default function TranscriptSearch() { const next = new Set(prev); if (isOpen) next.delete(groupId); else next.add(groupId); - if (next.size === prev.size && Array.from(next).every((v) => prev.has(v))) { + if ( + next.size === prev.size && + Array.from(next).every((v) => prev.has(v)) + ) { return prev; } persistUiCollapse({ collapsedGroups: Array.from(next).sort() }); @@ -974,7 +1014,12 @@ export default function TranscriptSearch() { }); return next; }); - }, [activeProfileName, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage]); + }, [ + activeProfileName, + buildDraftSnapshot, + promoteDraftsToCommitted, + writeStorage, + ]); const handleSaveAsProfile = useCallback(() => { if (typeof window === "undefined") return; @@ -1287,208 +1332,209 @@ export default function TranscriptSearch() { )} </summary> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400"> - {channelOptions.length > 1 && ( - <div className="flex flex-col gap-2 w-full"> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-zinc-500"> - Channels - </span> - <ChannelAllToggle - channelOptions={channelOptions} - draftExcludedChannels={draftExcludedChannels} - setDraftExcludedChannels={setDraftExcludedChannels} - /> - <button - type="button" - onClick={handleResetChannels} - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - Reset channels - </button> - <button - type="button" - onClick={handleResetAllFilters} - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - Reset all filters - </button> - <button - type="button" - onClick={handleResetEverything} - data-testid="reset-everything" - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - Reset everything - </button> - </div> - <div className="flex flex-col gap-1.5"> - {channelGroupings.map(({ group, channels }) => { - const isOpen = !collapsedGroups.has(group.id); - const selectedCount = channels.reduce( - (n, name) => (draftExcludedChannels.has(name) ? n : n + 1), - 0, - ); - return ( - <details - key={group.id} - open={isOpen} - onToggle={(e) => - toggleGroupCollapsed( - group.id, - (e.target as HTMLDetailsElement).open, - ) - } - className="rounded border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40" - > - <summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm"> - {group.name && ( - <span className="font-medium text-zinc-700 dark:text-zinc-200"> - {group.name} + {channelOptions.length > 1 && ( + <div className="flex flex-col gap-2 w-full"> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-zinc-500"> + Channels + </span> + <ChannelAllToggle + channelOptions={channelOptions} + draftExcludedChannels={draftExcludedChannels} + setDraftExcludedChannels={setDraftExcludedChannels} + /> + <button + type="button" + onClick={handleResetChannels} + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + Reset channels + </button> + <button + type="button" + onClick={handleResetAllFilters} + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + Reset all filters + </button> + <button + type="button" + onClick={handleResetEverything} + data-testid="reset-everything" + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + Reset everything + </button> + </div> + <div className="flex flex-col gap-1.5"> + {channelGroupings.map(({ group, channels }) => { + const isOpen = !collapsedGroups.has(group.id); + const selectedCount = channels.reduce( + (n, name) => + draftExcludedChannels.has(name) ? n : n + 1, + 0, + ); + return ( + <details + key={group.id} + open={isOpen} + onToggle={(e) => + toggleGroupCollapsed( + group.id, + (e.target as HTMLDetailsElement).open, + ) + } + className="rounded border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40" + > + <summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm"> + {group.name && ( + <span className="font-medium text-zinc-700 dark:text-zinc-200"> + {group.name} + </span> + )} + <span className="text-xs text-zinc-500"> + ({selectedCount}/{channels.length} selected) </span> + <span className="ml-auto flex items-center gap-2"> + <button + type="button" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelected(channels, true); + }} + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + All + </button> + <button + type="button" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelected(channels, false); + }} + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + None + </button> + </span> + </summary> + {group.description && ( + <div className="px-3 pt-0 pb-1 text-xs text-zinc-500"> + {group.description} + </div> )} - <span className="text-xs text-zinc-500"> - ({selectedCount}/{channels.length} selected) - </span> - <span className="ml-auto flex items-center gap-2"> - <button - type="button" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setSelected(channels, true); - }} - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - All - </button> - <button - type="button" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setSelected(channels, false); - }} - className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" - > - None - </button> - </span> - </summary> - {group.description && ( - <div className="px-3 pt-0 pb-1 text-xs text-zinc-500"> - {group.description} + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2"> + {channels.map((name) => { + const checked = !draftExcludedChannels.has(name); + return ( + <label + key={name} + className="flex items-center gap-1.5 select-none" + > + <input + type="checkbox" + checked={checked} + onChange={(e) => { + setDraftExcludedChannels((prev) => { + const next = new Set(prev); + if (e.target.checked) next.delete(name); + else next.add(name); + return next; + }); + }} + className="accent-blue-600" + /> + {name} + </label> + ); + })} </div> - )} - <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2"> - {channels.map((name) => { - const checked = !draftExcludedChannels.has(name); - return ( - <label - key={name} - className="flex items-center gap-1.5 select-none" - > - <input - type="checkbox" - checked={checked} - onChange={(e) => { - setDraftExcludedChannels((prev) => { - const next = new Set(prev); - if (e.target.checked) next.delete(name); - else next.add(name); - return next; - }); - }} - className="accent-blue-600" - /> - {name} - </label> - ); - })} - </div> - </details> - ); - })} + </details> + ); + })} + </div> </div> + )} + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-zinc-500"> + Type + </span> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNov} + onChange={(e) => setDraftNov(!e.target.checked)} + className="accent-blue-600" + /> + Videos + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNol} + onChange={(e) => setDraftNol(!e.target.checked)} + className="accent-blue-600" + /> + Livestreams + </label> + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-zinc-500"> + Audience + </span> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNaa} + onChange={(e) => setDraftNaa(!e.target.checked)} + className="accent-blue-600" + /> + All ages + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNar} + onChange={(e) => setDraftNar(!e.target.checked)} + className="accent-blue-600" + /> + Age-restricted + </label> + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-zinc-500"> + Availability + </span> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNav} + onChange={(e) => setDraftNav(!e.target.checked)} + className="accent-blue-600" + /> + Available + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNu} + onChange={(e) => setDraftNu(!e.target.checked)} + className="accent-blue-600" + /> + Unlisted + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNd} + onChange={(e) => setDraftNd(!e.target.checked)} + className="accent-blue-600" + /> + Deleted + </label> </div> - )} - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-zinc-500"> - Type - </span> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNov} - onChange={(e) => setDraftNov(!e.target.checked)} - className="accent-blue-600" - /> - Videos - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNol} - onChange={(e) => setDraftNol(!e.target.checked)} - className="accent-blue-600" - /> - Livestreams - </label> - </div> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-zinc-500"> - Audience - </span> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNaa} - onChange={(e) => setDraftNaa(!e.target.checked)} - className="accent-blue-600" - /> - All ages - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNar} - onChange={(e) => setDraftNar(!e.target.checked)} - className="accent-blue-600" - /> - Age-restricted - </label> - </div> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-zinc-500"> - Availability - </span> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNav} - onChange={(e) => setDraftNav(!e.target.checked)} - className="accent-blue-600" - /> - Available - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNu} - onChange={(e) => setDraftNu(!e.target.checked)} - className="accent-blue-600" - /> - Unlisted - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNd} - onChange={(e) => setDraftNd(!e.target.checked)} - className="accent-blue-600" - /> - Deleted - </label> - </div> </div> </details> )} @@ -1553,62 +1599,73 @@ export default function TranscriptSearch() { </div> </details> - {!hasActiveQuery && ( - <p className="text-sm text-zinc-500"> - Add a layer above to search. Combine layers with AND / OR to narrow - or widen results. - </p> - )} - - {hasActiveQuery && ( - <section data-testid="results-section"> - <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2 flex-wrap"> - <span data-testid="results-summary"> - Matching videos{" "} - {resultGroups.length > 0 - ? `(${resultGroups.length} video${ - resultGroups.length === 1 ? "" : "s" - }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})` - : "(0)"} - </span> - {indexLoading && ( - <span className="text-xs text-zinc-400 font-normal"> - loading index{indexProgress ? ` (${indexProgress})` : ""}… - </span> - )} - {searching && ( - <span className="text-xs text-zinc-400 font-normal"> - searched {processed}/{totalToProcess} - {pipelineActive ? "…" : ""} - </span> + <section data-testid="results-section"> + <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2 flex-wrap"> + <span data-testid="results-summary"> + {hasActiveQuery ? ( + <> + Matching videos{" "} + {resultGroups.length > 0 + ? `(${resultGroups.length} video${ + resultGroups.length === 1 ? "" : "s" + }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})` + : "(0)"} + </> + ) : ( + `All videos (${resultGroups.length})` )} - </h2> - {!indexLoading && resultGroups.length === 0 && !pipelineActive && ( - <p className="text-sm text-zinc-500">No matching videos.</p> - )} - {resultGroups.length > 0 && ( - <VirtualResultList - resultGroups={resultGroups} - leavesById={leavesById} - orderedLeafIds={orderedLeafIds} - activeVideo={activeVideo} - activeTime={activeTime} - openWithMode={openWithMode} - /> + </span> + {(indexLoading || (!hasActiveQuery && summariesLoading)) && ( + <span className="text-xs text-zinc-400 font-normal"> + loading index{indexProgress ? ` (${indexProgress})` : ""}… + </span> )} - {capped && ( - <div className="mt-3 flex justify-center"> - <button - type="button" - onClick={() => setHitLimit((l) => l + hitBatchSize)} - className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" - > - Load more results - </button> - </div> + {searching && ( + <span className="text-xs text-zinc-400 font-normal"> + searched {processed}/{totalToProcess} + {pipelineActive ? "…" : ""} + </span> )} - </section> - )} + </h2> + {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && ( + <p className="text-sm text-zinc-500 mb-2" data-testid="browse-hint"> + Showing all videos — type a query above to narrow. + </p> + )} + {hasActiveQuery + ? !indexLoading && + resultGroups.length === 0 && + !pipelineActive && ( + <p className="text-sm text-zinc-500">No matching videos.</p> + ) + : !summariesLoading && + resultGroups.length === 0 && ( + <p className="text-sm text-zinc-500"> + No videos match the current filters. + </p> + )} + {resultGroups.length > 0 && ( + <VirtualResultList + resultGroups={resultGroups} + leavesById={leavesById} + orderedLeafIds={orderedLeafIds} + activeVideo={activeVideo} + activeTime={activeTime} + openWithMode={openWithMode} + /> + )} + {capped && ( + <div className="mt-3 flex justify-center"> + <button + type="button" + onClick={() => setHitLimit((l) => l + hitBatchSize)} + className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" + > + Load more results + </button> + </div> + )} + </section> </div> ); } @@ -1645,9 +1702,7 @@ function buildResultRows( } function rowKey(row: ResultRow): string { - return row.kind === "card" - ? `c${row.cardIndex}` - : `c${row.cardIndex}:gap`; + return row.kind === "card" ? `c${row.cardIndex}` : `c${row.cardIndex}:gap`; } function VirtualResultList({ @@ -1665,10 +1720,7 @@ function VirtualResultList({ activeTime: number | null; openWithMode: (slug: string, hit?: LayerHit) => void; }) { - const flatRows = useMemo( - () => buildResultRows(resultGroups), - [resultGroups], - ); + const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]); const listRef = useRef<HTMLDivElement | null>(null); const [scrollMargin, setScrollMargin] = useState(0); @@ -1710,11 +1762,7 @@ function VirtualResultList({ const totalSize = rowVirtualizer.getTotalSize(); return ( - <div - ref={listRef} - className="relative" - style={{ height: totalSize }} - > + <div ref={listRef} className="relative" style={{ height: totalSize }}> {virtualItems.map((vi) => { const row = flatRows[vi.index]; return ( @@ -1789,7 +1837,8 @@ function ResultCard({ <span className="text-xs text-zinc-500 shrink-0"> {group.channel && `${group.channel} · `} {group.date} - {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} + {group.hits.length > 0 && + ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} </span> </button> <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> @@ -1834,7 +1883,9 @@ function ResultCard({ }`} > <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> - {h.scope === "metadata" ? "—" : formatSeconds(h.start)} + {h.scope === "metadata" + ? "—" + : formatSeconds(h.start)} </span> {h.track && h.track !== "live_chat" && ( <TrackBadge track={h.track} /> diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog -## [Unreleased] +## [0.2.2] - 2026-05-26 +- **Browse all videos without searching.** With no search query entered, the results list now shows every video matching the current filters (newest-first), instead of a "add a layer to search" prompt. Typing a query narrows the list as before; clearing it returns to the full browse view. This replaces the old workaround of searching regex `.` on titles. - **Collapsing the query builder to one layer no longer strips a hidden setting.** Deselecting "Show hits in results" on a layer and then deleting the other layers used to leave that lone layer permanently hits-off, with no control to fix it (the checkbox is hidden in the single-layer compact view). Collapsing back to compact now resets "Show hits" to on. ## [0.2.1] - 2026-05-25 diff --git a/export/e2e/browse-all.spec.ts b/export/e2e/browse-all.spec.ts @@ -0,0 +1,87 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + CHANNEL_SLUG, + VIDEO_CHAT_LARGE, + VIDEO_CHAT_SMALL, + VIDEO_TRANSCRIPT_ONLY, +} from "./fixtures/data"; +import { installRoutes } from "./helpers"; + +// Browse-all e2e: with no search query active the results section lists every +// video passing the committed filters (no hits). Typing a query narrows it; +// clearing it returns to the full browse list. + +const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`; +const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`; +const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`; +const ALL_SLUGS = [TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]; + +async function expectResultSlugs(page: Page, slugs: string[]) { + const cards = page.locator("[data-card-header]"); + await expect(async () => { + const got = await cards.evaluateAll((els) => + els.map((e) => e.getAttribute("data-result-slug") ?? ""), + ); + expect(got.slice().sort()).toEqual(slugs.slice().sort()); + }).toPass({ timeout: 15_000 }); +} + +test.describe("browse all (no query)", () => { + test.beforeEach(async ({ page }) => { + await installRoutes(page); + }); + + test("lists every video on load with no query", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("results-section")).toBeVisible(); + await expect(page.getByTestId("results-summary")).toHaveText( + "All videos (3)", + ); + await expect(page.getByTestId("browse-hint")).toBeVisible(); + await expectResultSlugs(page, ALL_SLUGS); + }); + + test("filters apply to the browse list on Search", async ({ page }) => { + await page.goto("/"); + await expectResultSlugs(page, ALL_SLUGS); + + // All fixture videos are non-livestream, so unchecking "Videos" excludes + // every one of them. + await page.getByRole("checkbox", { name: "Videos" }).uncheck(); + await page.getByTestId("search-submit").click(); + + await expect(page.getByTestId("results-summary")).toHaveText( + "All videos (0)", + ); + await expect( + page.getByText("No videos match the current filters."), + ).toBeVisible(); + }); + + test("typing a query narrows, clearing it returns to browse", async ({ + page, + }) => { + await page.goto("/"); + await expectResultSlugs(page, ALL_SLUGS); + + const leafInput = page.locator('input[data-testid^="leaf-query-"]').first(); + await leafInput.fill("alpha"); + await page.getByTestId("search-submit").click(); + + await expect(page.getByTestId("results-summary")).toContainText( + "Matching videos", + ); + await expectResultSlugs(page, ALL_SLUGS); + await expect(page.getByTestId("browse-hint")).toHaveCount(0); + + // Clearing the query drops back to the full browse list. + await leafInput.fill(""); + await page.getByTestId("search-submit").click(); + + await expect(page.getByTestId("results-summary")).toHaveText( + "All videos (3)", + ); + await expect(page.getByTestId("browse-hint")).toBeVisible(); + await expectResultSlugs(page, ALL_SLUGS); + }); +});