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:
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);
+ });
+});