commit f5ed0e75792343f195ed071ddcb604d1e67139ba
parent bd8ef15aa5f683f9a27f26b6b754e503ed7a6755
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 17:43:08 -0400
more collapsible sections
Diffstat:
4 files changed, 130 insertions(+), 10 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -209,6 +209,7 @@ export default function TranscriptSearch() {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
() => new Set(),
);
+ const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(false);
// Tracks whether the user has committed at least once this session. Used
// by the placeholder copy below — until the user has searched, we show a
@@ -421,6 +422,15 @@ export default function TranscriptSearch() {
manifestDefaultGroupId,
]);
+ const selectedChannelNames = useMemo(
+ () => channelOptions.filter((name) => !draftExcludedChannels.has(name)),
+ [channelOptions, draftExcludedChannels],
+ );
+ const selectedChannelsLabel = useMemo(
+ () => selectedChannelNames.join(", "),
+ [selectedChannelNames],
+ );
+
const passesFilter = useMemo(() => {
return (t: Summary) => {
if (committedChannels.has(t.channel)) return false;
@@ -662,6 +672,9 @@ export default function TranscriptSearch() {
setProfiles(stored.profiles);
setActiveProfileName(stored.activeProfileName);
setCollapsedGroups(new Set(stored.collapsedGroups ?? []));
+ if (typeof stored.filtersCollapsed === "boolean") {
+ setFiltersCollapsed(stored.filtersCollapsed);
+ }
}
const search = typeof window !== "undefined" ? window.location.search : "";
const params = new URLSearchParams(search);
@@ -756,26 +769,54 @@ export default function TranscriptSearch() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelOptions.length === 0, hydrated]);
- // Persist UI-only collapse state immediately on toggle.
- const persistCollapse = useCallback((next: Set<string>) => {
- const current = loadStoredState() ?? emptyStoredState();
- current.collapsedGroups = Array.from(next).sort();
- saveStoredState(current);
- }, []);
+ // Persist UI-only collapse state immediately on toggle. Loads + merges +
+ // saves so concurrent writes to other top-level fields aren't clobbered.
+ const persistUiCollapse = useCallback(
+ (
+ patch: Partial<
+ Pick<StoredState, "collapsedGroups" | "filtersCollapsed">
+ >,
+ ) => {
+ const current = loadStoredState() ?? emptyStoredState();
+ if (patch.collapsedGroups !== undefined) {
+ current.collapsedGroups = patch.collapsedGroups;
+ }
+ if (patch.filtersCollapsed !== undefined) {
+ current.filtersCollapsed = patch.filtersCollapsed;
+ }
+ saveStoredState(current);
+ },
+ [],
+ );
+ // React mounts <details open={…}> after the manifest loads — that transition
+ // fires a spurious `toggle` event whose handler can clobber the persisted
+ // state with the default "open" value. Guard the persist call so it only
+ // runs when the state actually changes.
const toggleGroupCollapsed = useCallback(
(groupId: string, isOpen: boolean) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (isOpen) next.delete(groupId);
else next.add(groupId);
- persistCollapse(next);
+ if (next.size === prev.size && Array.from(next).every((v) => prev.has(v))) {
+ return prev;
+ }
+ persistUiCollapse({ collapsedGroups: Array.from(next).sort() });
return next;
});
},
- [persistCollapse],
+ [persistUiCollapse],
);
+ const toggleFiltersCollapsed = useCallback(() => {
+ setFiltersCollapsed((prev) => {
+ const next = !prev;
+ persistUiCollapse({ filtersCollapsed: next });
+ return next;
+ });
+ }, [persistUiCollapse]);
+
const commitSearch = () => {
setSearchExecuted(true);
setCommittedRoot(draftRoot);
@@ -1187,7 +1228,36 @@ export default function TranscriptSearch() {
</form>
{channelOptions.length > 0 && (
- <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3">
+ <details open={!filtersCollapsed} className="-mt-3">
+ <summary
+ onClick={(e) => {
+ // Drive the open state from React rather than the browser's
+ // default toggle action. Avoids a spurious `toggle` event that
+ // fires when React first mounts <details open> after the
+ // manifest loads, which would clobber the persisted value.
+ e.preventDefault();
+ toggleFiltersCollapsed();
+ }}
+ className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400"
+ >
+ <span className="text-xs uppercase tracking-wide text-zinc-500">
+ Filters
+ </span>
+ <span className="text-xs text-zinc-500">
+ {selectedChannelNames.length} of {channelOptions.length} channel
+ {channelOptions.length === 1 ? "" : "s"}
+ </span>
+ {filtersCollapsed && selectedChannelNames.length > 0 && (
+ <span
+ className="text-xs text-zinc-500 truncate min-w-0 flex-1"
+ title={selectedChannelsLabel}
+ data-testid="filters-summary-channels"
+ >
+ {selectedChannelsLabel}
+ </span>
+ )}
+ </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">
@@ -1381,7 +1451,8 @@ export default function TranscriptSearch() {
Deleted
</label>
</div>
- </div>
+ </div>
+ </details>
)}
<details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400">
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -39,6 +39,9 @@ export type StoredState = {
// UI-only: persistent collapse state per group id. Lives here rather than
// inside snapshots so toggling a section never marks a profile dirty.
collapsedGroups?: string[];
+ // UI-only: persistent collapse state for the whole filter section.
+ // Absent => open (default).
+ filtersCollapsed?: boolean;
};
export function emptySnapshot(): FilterSnapshot {
@@ -120,6 +123,9 @@ function parseStored(raw: string): StoredState | null {
if (isStringArray(r.collapsedGroups)) {
out.collapsedGroups = r.collapsedGroups.slice();
}
+ if (typeof r.filtersCollapsed === "boolean") {
+ out.filtersCollapsed = r.filtersCollapsed;
+ }
return out;
}
diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts
@@ -517,6 +517,48 @@ test.describe("export TranscriptSearch — advanced options persistence", () =>
});
});
+test.describe("export TranscriptSearch — filters collapse persistence", () => {
+ test.beforeEach(async ({ page }) => {
+ await installFixtureRoutes(page);
+ await page.goto(EXPORT_BASE);
+ await waitForHydration(page);
+ });
+
+ test("filters section is open by default", async ({ page }) => {
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible();
+ const summary = page.locator("summary").filter({ hasText: "Filters" });
+ await expect(summary).toBeVisible();
+ await expect(summary.locator("xpath=..")).toHaveJSProperty("open", true);
+ });
+
+ test("clicking summary collapses and reveals compact channel list", async ({
+ page,
+ }) => {
+ const summary = page.locator("summary").filter({ hasText: "Filters" });
+ await summary.click();
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeHidden();
+ await expect(summary).toContainText(/\d+ of \d+ channel/);
+ const compactList = page.getByTestId("filters-summary-channels");
+ await expect(compactList).toBeVisible();
+ await expect(compactList).toContainText(CHANNEL);
+ });
+
+ test("collapse state persists across reload", async ({ page }) => {
+ await page.locator("summary").filter({ hasText: "Filters" }).click();
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeHidden();
+
+ await page.reload();
+ await waitForHydration(page);
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeHidden();
+
+ await page.locator("summary").filter({ hasText: "Filters" }).click();
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible();
+ await page.reload();
+ await waitForHydration(page);
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible();
+ });
+});
+
test.describe("export footer", () => {
test.beforeEach(async ({ page }) => {
await installFixtureRoutes(page);
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -4,6 +4,7 @@
### Changed
- Reorganised the search/filter UI to put the profile selector at the top, framing both the query builder and the filters. The "Profile" row is no longer gated on having more than one channel — profiles cover the layered query *and* the filters, so the selector is meaningful even with a single channel. The "Share current search" button moved into that same row (it's the URL-shareable counterpart of a locally-saved profile). The dirty-profile indicator switched from the literal text "(unsaved)" to a small amber `•` — "(unsaved)" was doing double duty as both the placeholder option label and the dirty marker, which made the wording ambiguous.
+- The filter section (channels, Type, Audience, Availability) is now collapsible — same pattern as Advanced options. The summary stays visible whether open or closed and shows "{N} of {Total} channels" with a compact comma-separated list of the selected channel names when collapsed (truncated with an ellipsis if it overflows; full list available on hover via the row's `title`). Open/closed state persists across reloads in localStorage alongside the existing channel-group collapse state, and toggling never marks a profile dirty.
- Removed the "slow / medium / fast" cost badges next to each layer's scope dropdown. They read as a warning on the primary mode (transcripts) without giving users any actionable choice.
- Search results now render through a virtualised list (`@tanstack/react-virtual`), one element per matching video. Cards mount in full when they enter the viewport (header + leaf sections + hits laid out in normal CSS flow inside the card) and unmount once scrolled away — earlier iterations virtualised every row inside each card, which produced occasional empty bands between a card's header and its leaf sections while ResizeObserver caught up to row sizes. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous virtualised list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabelled now that pagination of cards is no longer needed).