commit 4ea812b3c23899c3148e070363352a9453077734
parent 163e4481a7d621df00ae6c2647169dbab3c3a5fa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 20:55:37 -0400
search: the bar is a query box and one row of chips
The profile row and five filter sections used to sit between a visitor
and the search box — two phone screens of chrome before the first
result. The bar is now the query, the submit and a wrapping chip row;
the filters (profiles included, since a profile is a saved filter set)
live behind a Filters chip in a sheet below xl, and inline as before
from xl. On a phone the first result card starts 400px down instead of
1000.
WorkspaceSearchBar splits into SearchBar, FiltersPanel and
FiltersContainer, and the [Search|Chat|Split] control moves next to the
search box — so its state lifts into a WorkspaceViewProvider that wraps
both the bar and the panes it governs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
12 files changed, 1384 insertions(+), 999 deletions(-)
diff --git a/common/components/FiltersContainer.tsx b/common/components/FiltersContainer.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+// Where FiltersPanel lives, which depends on how much room there is.
+//
+// • from xl — inline under the search bar, collapsible, exactly as it has
+// always been on a desktop. The group-chip row is designed to pack uniform
+// chips across the full width, so a narrow rail would be a downgrade there.
+// • below xl — a sheet behind the "Filters" chip, with an Apply button that
+// commits and closes. That is what gets the two screens of filter stack out
+// from between a phone visitor and their results.
+//
+// Both readers ask the same media query, and `useMediaQuery` re-reads `matches`
+// synchronously, so the trigger and the inline block can never both be showing
+// the same single-mount panel.
+
+import { useState } from "react";
+import { ListFilterIcon } from "lucide-react";
+import { Button } from "./ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetTitle,
+ SheetTrigger,
+} from "./ui/sheet";
+import FiltersPanel from "./FiltersPanel";
+import { useMediaQuery } from "../lib/useMediaQuery";
+
+export function FiltersTrigger({
+ activeCount,
+ onApply,
+}: {
+ activeCount: number;
+ onApply: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ // From sm the sheet comes in from the right like the menu; on a phone it
+ // rises from the bottom, where a thumb is.
+ const fromSide = useMediaQuery("(min-width: 640px)");
+
+ return (
+ <Sheet open={open} onOpenChange={setOpen}>
+ <SheetTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ data-testid="filters-trigger"
+ className="shrink-0 text-xs text-muted-foreground"
+ >
+ <ListFilterIcon aria-hidden="true" />
+ Filters
+ {activeCount > 0 && (
+ <span className="rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground">
+ {activeCount}
+ </span>
+ )}
+ </Button>
+ </SheetTrigger>
+ <SheetContent
+ side={fromSide ? "right" : "bottom"}
+ aria-describedby={undefined}
+ className="max-h-[90dvh] gap-0 overflow-y-auto sm:max-w-md"
+ >
+ <SheetTitle className="px-4 pt-4 text-base">Filters</SheetTitle>
+ <div className="px-4 pb-4 pt-2">
+ <FiltersPanel forceOpen />
+ </div>
+ {/* Sticky so a long filter list never buries the one button that
+ applies it. Deliberately NOT `search-submit`: there is exactly one
+ of those, and it is in the bar. */}
+ <div className="sticky bottom-0 border-t border-border bg-background px-4 py-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]">
+ <Button
+ type="button"
+ className="w-full"
+ onClick={() => {
+ onApply();
+ setOpen(false);
+ }}
+ >
+ Apply filters
+ </Button>
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
+
+// The inline placement. Renders nothing below xl, where FiltersTrigger owns the
+// panel — one mount, never two.
+export function FiltersSection() {
+ const inline = useMediaQuery("(min-width: 1280px)");
+ if (!inline) return null;
+ return <FiltersPanel />;
+}
diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx
@@ -0,0 +1,863 @@
+"use client";
+
+// The filters half of the search bar: the profile row, every filter (channels,
+// type, audience, availability, dates) and the advanced throughput knobs.
+//
+// It is ONE component with ONE mount, placed by FiltersContainer — inline under
+// the bar from xl, inside a sheet below it. The profile row lives here rather
+// than above the query builder because a profile IS a saved filter+query set,
+// and on a phone it was three rows of chrome above the search box.
+//
+// The outer `<details data-testid="filters-panel">` is load-bearing: several
+// specs locate a channel group card as `details details`, so this wrapper has
+// to be the outer one. It also doubles as the inline collapse, driven by the
+// session's persisted `filtersCollapsed`.
+
+import { Fragment, useCallback } from "react";
+import { ChevronRightIcon } from "lucide-react";
+import {
+ MISSING_STATES,
+ VIDEO_STATE_LABELS,
+ type VideoState,
+} from "../lib/availability";
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
+import { cn } from "../lib/utils";
+import { clearAdvanced } from "./exportAdvancedStorage";
+import { ymdToInput, inputToYmd } from "../lib/ymd";
+import {
+ useSearchSession,
+ DEFAULT_MAX_HITS,
+ DEFAULT_FETCH_CONCURRENCY,
+ DEFAULT_FLUSH_INTERVAL_MS,
+} from "./SearchSessionContext";
+import { useSearchData } from "./SearchDataContext";
+
+export default function FiltersPanel({
+ // Inside the sheet the sheet itself IS the disclosure, so the wrapper stays
+ // open and its summary is not offered as a second control.
+ forceOpen = false,
+}: {
+ forceOpen?: boolean;
+}) {
+ const {
+ profiles,
+ activeProfileName,
+ profileDirty,
+ shareCopied,
+ handleLoadProfile,
+ handleSaveProfile,
+ handleSaveAsProfile,
+ handleRenameProfile,
+ handleDeleteProfile,
+ handleRevertProfile,
+ handleShareCurrentSearch,
+ channelOptions,
+ filtersCollapsed,
+ toggleFiltersCollapsed,
+ selectedChannelNames,
+ selectedChannelsLabel,
+ draftExcludedChannels,
+ setDraftExcludedChannels,
+ handleResetChannels,
+ handleResetAllFilters,
+ handleResetEverything,
+ channelGroupings,
+ collapsedGroups,
+ toggleGroupCollapsed,
+ setSelected,
+ channelLabelByKey,
+ draftNov,
+ draftNop,
+ setDraftNop,
+ setDraftNov,
+ draftNol,
+ setDraftNol,
+ draftNaa,
+ setDraftNaa,
+ draftNar,
+ setDraftNar,
+ draftStates,
+ setDraftStates,
+ draftDateFrom,
+ setDraftDateFrom,
+ draftDateTo,
+ setDraftDateTo,
+ hitBatchValue,
+ setHitBatchValue,
+ hitBatchUnlimited,
+ setHitBatchUnlimited,
+ fetchConcurrency,
+ setFetchConcurrency,
+ flushIntervalMs,
+ setFlushIntervalMs,
+ setHitLimit,
+ } = useSearchSession();
+
+ // Only offer the Posts type toggle on a site that actually ships a posts
+ // corpus, so a pure-video deployment's filter row is unchanged.
+ const { postsManifest } = useSearchData();
+ const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0;
+
+ const toggleState = useCallback(
+ (state: VideoState, keep: boolean) => {
+ setDraftStates((prev) => {
+ const next = new Set(prev);
+ if (keep) next.add(state);
+ else next.delete(state);
+ return next;
+ });
+ },
+ [setDraftStates],
+ );
+ const setAllMissing = useCallback(
+ (keep: boolean) => {
+ setDraftStates((prev) => {
+ const next = new Set(prev);
+ for (const s of MISSING_STATES) {
+ if (keep) next.add(s);
+ else next.delete(s);
+ }
+ return next;
+ });
+ },
+ [setDraftStates],
+ );
+ const allMissingKept = MISSING_STATES.every((s) => draftStates.has(s));
+ const someMissingKept = MISSING_STATES.some((s) => draftStates.has(s));
+
+ return (
+ <details
+ data-testid="filters-panel"
+ open={forceOpen || !filtersCollapsed}
+ className="min-w-0"
+ >
+ <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();
+ if (!forceOpen) toggleFiltersCollapsed();
+ }}
+ className={cn(
+ "cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground",
+ forceOpen && "hidden",
+ )}
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Filters
+ </span>
+ {channelOptions.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ {selectedChannelNames.length} of {channelOptions.length} channel
+ {channelOptions.length === 1 ? "" : "s"}
+ </span>
+ )}
+ {filtersCollapsed && selectedChannelNames.length > 0 && (
+ <span
+ className="text-xs text-muted-foreground truncate min-w-0 flex-1"
+ title={selectedChannelsLabel}
+ data-testid="filters-summary-channels"
+ >
+ {selectedChannelsLabel}
+ </span>
+ )}
+ </summary>
+
+ <div className="mt-2 flex flex-col gap-4">
+ <ProfilesRow
+ profileNames={Object.keys(profiles).sort()}
+ activeProfileName={activeProfileName}
+ profileDirty={profileDirty}
+ shareCopied={shareCopied}
+ onSelect={handleLoadProfile}
+ onSave={handleSaveProfile}
+ onSaveAs={handleSaveAsProfile}
+ onRename={handleRenameProfile}
+ onDelete={handleDeleteProfile}
+ onRevert={handleRevertProfile}
+ onShareCurrentSearch={handleShareCurrentSearch}
+ />
+
+ {channelOptions.length > 0 && (
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
+ {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-muted-foreground">
+ Channels
+ </span>
+ <ChannelAllToggle
+ channelOptions={channelOptions}
+ draftExcludedChannels={draftExcludedChannels}
+ setDraftExcludedChannels={setDraftExcludedChannels}
+ />
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetChannels}
+ className="text-xs text-muted-foreground"
+ >
+ Reset channels
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetAllFilters}
+ className="text-xs text-muted-foreground"
+ >
+ Reset all filters
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetEverything}
+ data-testid="reset-everything"
+ className="text-xs text-muted-foreground"
+ >
+ Reset everything
+ </Button>
+ </div>
+ <div className="flex flex-wrap items-start gap-1.5">
+ {channelGroupings.map(({ group, channels }) => {
+ if (group.inline) {
+ // Inline group: no collapsible box — each member channel
+ // renders as its own loose chip flowing among the group
+ // chips, styled like a collapsed group chip.
+ return (
+ <Fragment key={group.id}>
+ {channels.map((key) => {
+ const label = channelLabelByKey.get(key) ?? key;
+ return (
+ <label
+ key={key}
+ title={label}
+ data-testid="inline-channel-chip"
+ className="flex items-center gap-2 rounded border border-border bg-card/60 px-2.5 py-1.5 text-sm cursor-pointer select-none min-w-0 max-w-full w-full sm:w-auto sm:min-w-48"
+ >
+ <span className="flex items-center shrink-0">
+ <Checkbox
+ checked={!draftExcludedChannels.has(key)}
+ onCheckedChange={(value) =>
+ setSelected([key], value === true)
+ }
+ />
+ </span>
+ <span className="truncate">{label}</span>
+ </label>
+ );
+ })}
+ </Fragment>
+ );
+ }
+ 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}
+ className={cn(
+ "rounded border border-border bg-card/60 min-w-0 max-w-full w-full",
+ !isOpen && "sm:w-auto sm:min-w-48",
+ )}
+ >
+ <summary
+ onClick={(e) => {
+ // Drive the open state from React rather than the
+ // browser's default toggle action — same rationale
+ // as the outer Filters summary above.
+ e.preventDefault();
+ toggleGroupCollapsed(group.id, !isOpen);
+ }}
+ className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm min-w-0"
+ title={!isOpen ? group.description : undefined}
+ >
+ <ChevronRightIcon
+ aria-hidden="true"
+ className={cn(
+ "size-3.5 shrink-0 text-muted-foreground transition-transform",
+ isOpen && "rotate-90",
+ )}
+ />
+ <span
+ onClick={(e) => {
+ // Isolate checkbox clicks from the summary: no
+ // expand/collapse, just the group bulk-toggle.
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ className="flex items-center shrink-0"
+ >
+ <Checkbox
+ checked={
+ selectedCount === 0
+ ? false
+ : selectedCount === channels.length
+ ? true
+ : "indeterminate"
+ }
+ onCheckedChange={(value) =>
+ setSelected(channels, value === true)
+ }
+ aria-label={`Select all in ${group.name || "group"}`}
+ />
+ </span>
+ {group.name && (
+ <span
+ className="font-medium text-foreground flex items-center gap-1.5 min-w-0"
+ title={group.name}
+ >
+ {group.accent && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: group.accent }}
+ />
+ )}
+ <span className="truncate">{group.name}</span>
+ </span>
+ )}
+ <span
+ className={cn(
+ "text-xs text-muted-foreground shrink-0",
+ !isOpen && "ml-auto",
+ )}
+ >
+ {selectedCount}/{channels.length}
+ <span className="sr-only"> selected</span>
+ </span>
+ {isOpen && (
+ <span className="ml-auto flex items-center gap-2">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, true);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, false);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ None
+ </Button>
+ </span>
+ )}
+ </summary>
+ {group.description && (
+ <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
+ {group.description}
+ </div>
+ )}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2">
+ {channels.map((key) => {
+ const checked = !draftExcludedChannels.has(key);
+ return (
+ <label
+ key={key}
+ className="flex items-center gap-1.5 select-none"
+ >
+ <Checkbox
+ checked={checked}
+ onCheckedChange={(value) => {
+ setDraftExcludedChannels((prev) => {
+ const next = new Set(prev);
+ if (value === true) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }}
+ />
+ {channelLabelByKey.get(key) ?? key}
+ </label>
+ );
+ })}
+ </div>
+ </details>
+ );
+ })}
+ </div>
+ </div>
+ )}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Type
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNov}
+ onCheckedChange={(value) => setDraftNov(value !== true)}
+ />
+ Videos
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNol}
+ onCheckedChange={(value) => setDraftNol(value !== true)}
+ />
+ Livestreams
+ </label>
+ {/* The social-post corpus is a third media kind, not a video
+ sub-type — without its own toggle the video/livestream pair
+ would silently drop every post. Only offered when the site
+ actually ships posts. */}
+ {hasPostsCorpus && (
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNop}
+ onCheckedChange={(value) => setDraftNop(value !== true)}
+ />
+ Posts
+ </label>
+ )}
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Audience
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNaa}
+ onCheckedChange={(value) => setDraftNaa(value !== true)}
+ />
+ All ages
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNar}
+ onCheckedChange={(value) => setDraftNar(value !== true)}
+ />
+ Age-restricted
+ </label>
+ </div>
+ {/* Availability is one enum, not three independent booleans:
+ "Missing" is an umbrella over the five ways a video can leave
+ its channel listing, so it renders as a parent with the leaves
+ nested under it. Toggling the parent sets or clears all five;
+ it goes indeterminate when only some are kept. */}
+ <div className="flex flex-wrap items-start gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground pt-0.5">
+ Availability
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ data-testid="av-available"
+ checked={draftStates.has("available")}
+ onCheckedChange={(value) =>
+ toggleState("available", value === true)
+ }
+ />
+ Available
+ </label>
+ <div className="flex flex-col gap-1">
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ data-testid="av-missing"
+ checked={
+ allMissingKept
+ ? true
+ : someMissingKept
+ ? "indeterminate"
+ : false
+ }
+ onCheckedChange={(value) => setAllMissing(value === true)}
+ />
+ Missing
+ </label>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-5">
+ {MISSING_STATES.map((state) => (
+ <label
+ key={state}
+ className="flex items-center gap-1.5 select-none"
+ >
+ <Checkbox
+ data-testid={`av-${state}`}
+ checked={draftStates.has(state)}
+ onCheckedChange={(value) =>
+ toggleState(state, value === true)
+ }
+ />
+ {VIDEO_STATE_LABELS[state]}
+ </label>
+ ))}
+ </div>
+ </div>
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Date
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ From
+ <input
+ type="date"
+ data-testid="date-from"
+ value={ymdToInput(draftDateFrom)}
+ onChange={(e) =>
+ setDraftDateFrom(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
+ />
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ To
+ <input
+ type="date"
+ data-testid="date-to"
+ value={ymdToInput(draftDateTo)}
+ onChange={(e) =>
+ setDraftDateTo(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
+ />
+ </label>
+ </div>
+ </div>
+ )}
+
+ {/* Throughput knobs: nobody sets these on a first visit, and on a phone
+ they were four more rows between the query and the results. */}
+ <details className="text-sm text-muted-foreground">
+ <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
+ Advanced options
+ </summary>
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60">
+ <LimitField
+ label="Max hits per batch"
+ hint="Pause after this many matches; “Show more” adds another batch."
+ value={hitBatchValue}
+ unlimited={hitBatchUnlimited}
+ defaultValue={DEFAULT_MAX_HITS}
+ onValueChange={setHitBatchValue}
+ onUnlimitedChange={setHitBatchUnlimited}
+ />
+ <NumberField
+ label="Fetch concurrency"
+ hint="Parallel transcript downloads while searching. Applies on next search."
+ value={fetchConcurrency}
+ defaultValue={DEFAULT_FETCH_CONCURRENCY}
+ min={1}
+ max={32}
+ onChange={setFetchConcurrency}
+ />
+ <NumberField
+ label="Flush interval (ms)"
+ hint="UI update cadence during search. Applies on next search."
+ value={flushIntervalMs}
+ defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
+ min={16}
+ max={2000}
+ onChange={setFlushIntervalMs}
+ />
+ <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHitBatchUnlimited(true);
+ setHitLimit(Infinity);
+ }}
+ className="text-xs"
+ >
+ Search through all transcripts
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHitBatchValue(DEFAULT_MAX_HITS);
+ setHitBatchUnlimited(false);
+ setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
+ setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
+ clearAdvanced();
+ }}
+ className="text-xs"
+ >
+ Reset to defaults
+ </Button>
+ </div>
+ </div>
+ </details>
+ </div>
+ </details>
+ );
+}
+
+function ProfilesRow({
+ profileNames,
+ activeProfileName,
+ profileDirty,
+ shareCopied,
+ onSelect,
+ onSave,
+ onSaveAs,
+ onRename,
+ onDelete,
+ onRevert,
+ onShareCurrentSearch,
+}: {
+ profileNames: string[];
+ activeProfileName: string | null;
+ profileDirty: boolean;
+ shareCopied: boolean;
+ onSelect: (name: string) => void;
+ onSave: () => void;
+ onSaveAs: () => void;
+ onRename: () => void;
+ onDelete: () => void;
+ onRevert: () => void;
+ onShareCurrentSearch: () => void;
+}) {
+ const showRevert = activeProfileName != null && profileDirty;
+ return (
+ <div
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
+ data-testid="profiles-row"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Profile
+ </span>
+ <select
+ value={activeProfileName ?? ""}
+ onChange={(e) => onSelect(e.target.value)}
+ data-testid="profile-select"
+ className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm"
+ >
+ <option value="">(unsaved)</option>
+ {profileNames.map((n) => (
+ <option key={n} value={n}>
+ {n}
+ </option>
+ ))}
+ </select>
+ {profileDirty && (
+ <span
+ role="status"
+ aria-label="profile has unsaved changes"
+ data-testid="profile-dirty-dot"
+ className="text-base leading-none text-warning"
+ >
+ •
+ </span>
+ )}
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onSave}
+ disabled={!activeProfileName || !profileDirty}
+ className="text-xs text-muted-foreground"
+ >
+ Save
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onSaveAs}
+ className="text-xs text-muted-foreground"
+ >
+ Save as…
+ </Button>
+ {showRevert && (
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onRevert}
+ data-testid="profile-revert"
+ className="text-xs text-muted-foreground"
+ >
+ Revert
+ </Button>
+ )}
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onRename}
+ disabled={!activeProfileName}
+ className="text-xs text-muted-foreground"
+ >
+ Rename
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onDelete}
+ disabled={!activeProfileName}
+ className="text-xs text-destructive"
+ >
+ Delete
+ </Button>
+ <div className="ml-auto flex items-center gap-x-3">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onShareCurrentSearch}
+ title={
+ shareCopied
+ ? "Copied!"
+ : "Copy link encoding the current search + filters"
+ }
+ className="text-xs text-muted-foreground"
+ >
+ {shareCopied ? "Link copied!" : "Share current search"}
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function ChannelAllToggle({
+ channelOptions,
+ draftExcludedChannels,
+ setDraftExcludedChannels,
+}: {
+ channelOptions: string[];
+ draftExcludedChannels: Set<string>;
+ setDraftExcludedChannels: (next: Set<string>) => void;
+}) {
+ const allChecked = draftExcludedChannels.size === 0;
+ return (
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={allChecked}
+ onCheckedChange={() => {
+ setDraftExcludedChannels(
+ allChecked ? new Set(channelOptions) : new Set(),
+ );
+ }}
+ />
+ All
+ </label>
+ );
+}
+
+function NumberField({
+ label,
+ hint,
+ value,
+ defaultValue,
+ min,
+ max,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ defaultValue: number;
+ min: number;
+ max: number;
+ onChange: (n: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium text-foreground">
+ {label}
+ </span>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={Number.isFinite(value) ? value : ""}
+ placeholder={String(defaultValue)}
+ disabled={disabled}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onChange(defaultValue);
+ return;
+ }
+ const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ </label>
+ );
+}
+
+function LimitField({
+ label,
+ hint,
+ value,
+ unlimited,
+ defaultValue,
+ onValueChange,
+ onUnlimitedChange,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ unlimited: boolean;
+ defaultValue: number;
+ onValueChange: (n: number) => void;
+ onUnlimitedChange: (b: boolean) => void;
+}) {
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex items-baseline justify-between gap-2">
+ <span className="text-xs font-medium text-foreground">
+ {label}
+ </span>
+ <label className="flex items-center gap-1 text-xs select-none">
+ <Checkbox
+ checked={unlimited}
+ onCheckedChange={(value) => onUnlimitedChange(value === true)}
+ />
+ No limit
+ </label>
+ </div>
+ <input
+ type="number"
+ aria-label={label}
+ min={1}
+ max={1_000_000}
+ value={unlimited ? "" : value}
+ placeholder={unlimited ? "∞" : String(defaultValue)}
+ disabled={unlimited}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onValueChange(defaultValue);
+ return;
+ }
+ const n = Math.max(1, Math.floor(Number(raw)));
+ if (Number.isFinite(n)) onValueChange(n);
+ }}
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ </div>
+ );
+}
diff --git a/common/components/QueryBuilder.tsx b/common/components/QueryBuilder.tsx
@@ -23,6 +23,7 @@ import {
type LeafNode,
type QueryNode,
} from "../lib/searchQuery";
+import type { ReactNode } from "react";
import type { GroupState, LeafState } from "../lib/searchEval";
import QueryGroupView from "./QueryGroupView";
import QueryLeafView from "./QueryLeafView";
@@ -33,13 +34,49 @@ type Props = {
onChange: (next: GroupNode) => void;
leafStates: ReadonlyMap<string, LeafState>;
groupStates: ReadonlyMap<string, GroupState>;
+ /** Slotted beside the compact leaf's input — the search bar's submit button. */
+ inputTrailing?: ReactNode;
+ /** The search bar renders "+ Add layer" / "+ Add group" in its own chip row
+ * (see `compactLayerActions`), so it turns the builder's own pair off rather
+ * than shipping two of each. */
+ showCompactActions?: boolean;
};
+// The two compact-mode tree edits, as callables. Exported because the search
+// bar renders them as chips OUTSIDE this component and they must stay one
+// implementation — the same normalize-on-write contract as `emit` below.
+export function compactLayerActions(
+ root: GroupNode,
+ onChange: (next: GroupNode) => void,
+) {
+ const emit = (next: GroupNode) => onChange(normalizeRoot(next));
+ return {
+ addLayer: () =>
+ emit(
+ insertChild(
+ root,
+ root.id,
+ newLeaf({ scope: "transcripts", contributeHits: true }),
+ ),
+ ),
+ addGroup: () =>
+ emit(
+ insertChild(
+ root,
+ root.id,
+ newGroup({ op: "OR", children: [newLeaf({ scope: "transcripts" })] }),
+ ),
+ ),
+ };
+}
+
export default function QueryBuilder({
root,
onChange,
leafStates,
groupStates,
+ inputTrailing,
+ showCompactActions = true,
}: Props) {
// All mutations flow through `emit` so the result is normalized: when a
// change collapses the tree into compact mode, options the compact UI hides
@@ -73,6 +110,7 @@ export default function QueryBuilder({
leaf={leaf}
state={leafStates.get(leaf.id)}
compact
+ inputTrailing={inputTrailing}
canDelete={false}
canNegate={false}
onChange={(next) => onChangeNode(leaf.id, next)}
@@ -80,40 +118,29 @@ export default function QueryBuilder({
/* not reachable: canDelete=false in compact mode */
}}
/>
- <div className="flex items-center gap-2 text-xs">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={() =>
- onAddChild(
- root.id,
- newLeaf({ scope: "transcripts", contributeHits: true }),
- )
- }
- data-testid="compact-add-layer"
- className="text-xs text-muted-foreground"
- >
- + Add layer
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={() =>
- onAddChild(
- root.id,
- newGroup({
- op: "OR",
- children: [newLeaf({ scope: "transcripts" })],
- }),
- )
- }
- className="text-xs text-muted-foreground"
- >
- + Add group
- </Button>
- </div>
+ {showCompactActions && (
+ <div className="flex items-center gap-2 text-xs">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={() => compactLayerActions(root, onChange).addLayer()}
+ data-testid="compact-add-layer"
+ className="text-xs text-muted-foreground"
+ >
+ + Add layer
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={() => compactLayerActions(root, onChange).addGroup()}
+ className="text-xs text-muted-foreground"
+ >
+ + Add group
+ </Button>
+ </div>
+ )}
</div>
);
}
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -8,7 +8,7 @@
// scope dropdown, query input, regex / show-hits / NOT toggles, a live
// "→ N videos" count, a cached badge when applicable, and a delete button.
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import type { LeafNode, LayerScope } from "../lib/searchQuery";
import type { LeafState } from "../lib/searchEval";
import { matchAliases } from "../lib/searchAliases";
@@ -26,6 +26,9 @@ type Props = {
leaf: LeafNode;
state: LeafState | undefined;
compact?: boolean;
+ /** Rendered beside the query input in compact mode — where the search bar
+ * puts its submit button, so the two sit on one line at any width. */
+ inputTrailing?: ReactNode;
canDelete: boolean;
canNegate?: boolean;
isOnlyLeaf?: boolean;
@@ -47,6 +50,7 @@ export default function QueryLeafView({
leaf,
state,
compact = false,
+ inputTrailing,
canDelete,
canNegate = true,
onChange,
@@ -170,7 +174,9 @@ export default function QueryLeafView({
data-testid={`leaf-query-${leaf.id}`}
aria-invalid={regexInvalid || undefined}
className={cn(
- "flex-1 min-w-0",
+ // text-base below sm: iOS zooms the page on focus for anything under
+ // 16px, and it never zooms back out.
+ "flex-1 min-w-0 h-11 text-base sm:h-9 sm:text-sm",
regexInvalid && "border-destructive",
leaf.useRegex && "font-mono",
)}
@@ -183,7 +189,7 @@ export default function QueryLeafView({
value={leaf.scope}
onChange={(e) => update({ scope: e.target.value as LayerScope })}
data-testid={`leaf-scope-${leaf.id}`}
- className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-xs font-mono"
+ className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-base sm:text-xs font-mono"
>
{(
[
@@ -241,6 +247,7 @@ export default function QueryLeafView({
<div className="flex items-center gap-2">
<LayerSwatch leafId={leaf.id} />
{queryInput}
+ {inputTrailing}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{scopeSelect}
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+// The standard path, and the only part of the bar a first-time visitor has to
+// understand: a query box, a Search button, and one row of chips for everything
+// else. Below lg it sticks under the header, so the search box is always one
+// glance away no matter how far down the results the reader has gone.
+//
+// Everything that used to sit ABOVE this — the profile row, five filter
+// sections, the availability tree, the date pickers — now lives in FiltersPanel
+// behind the Filters chip (or inline from xl). That stack was two phone screens
+// tall and it sat between the visitor and the thing they came for.
+
+import { useMemo, type ReactNode } from "react";
+import { LayersIcon, SearchIcon, Share2Icon } from "lucide-react";
+import { isCompactRoot } from "../lib/searchQuery";
+import { MISSING_STATES } from "../lib/availability";
+import { Button } from "./ui/button";
+import QueryBuilder, { compactLayerActions } from "./QueryBuilder";
+import { FiltersTrigger } from "./FiltersContainer";
+import { useMediaQuery } from "../lib/useMediaQuery";
+import { useSearchSession } from "./SearchSessionContext";
+
+export default function SearchBar({ nav }: { nav?: ReactNode }) {
+ const {
+ commitSearch,
+ mounted,
+ draftRoot,
+ setDraftRoot,
+ leafStates,
+ groupStates,
+ queryDirty,
+ filtersDirty,
+ handleResetLayers,
+ layersResetDisabled,
+ hasSubs,
+ liveChatTotalCount,
+ shareCopied,
+ handleShareCurrentSearch,
+ channelOptions,
+ draftExcludedChannels,
+ draftNov,
+ draftNol,
+ draftNop,
+ draftNaa,
+ draftNar,
+ draftStates,
+ draftDateFrom,
+ draftDateTo,
+ } = useSearchSession();
+
+ // From xl the filters are an inline block under the bar with their own
+ // disclosure, so the chip (and the bar's share button, which the profile row
+ // already carries) would be a second copy of a control that is on screen.
+ const inlineFilters = useMediaQuery("(min-width: 1280px)");
+
+ const compact = isCompactRoot(draftRoot);
+ const layers = compactLayerActions(draftRoot, setDraftRoot);
+
+ // What the Filters chip's badge counts: anything the reader has narrowed.
+ const activeFilters = useMemo(() => {
+ let n = 0;
+ if (draftExcludedChannels.size > 0) n += 1;
+ if (draftNov || draftNol || draftNop) n += 1;
+ if (draftNaa || draftNar) n += 1;
+ if (draftStates.size < MISSING_STATES.length + 1) n += 1;
+ if (draftDateFrom || draftDateTo) n += 1;
+ return n;
+ }, [
+ draftExcludedChannels,
+ draftNov,
+ draftNol,
+ draftNop,
+ draftNaa,
+ draftNar,
+ draftStates,
+ draftDateFrom,
+ draftDateTo,
+ ]);
+
+ const submit = (
+ <Button
+ type="submit"
+ data-testid="search-submit"
+ data-dirty={queryDirty || filtersDirty ? "true" : "false"}
+ variant={queryDirty || filtersDirty ? "default" : "outline"}
+ className="shrink-0 h-11 sm:h-9"
+ >
+ <SearchIcon aria-hidden="true" />
+ {/* The accessible name stays "Search" at every width; only the ink goes. */}
+ <span className="sr-only sm:not-sr-only">Search</span>
+ </Button>
+ );
+
+ const resetLayers = (
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetLayers}
+ disabled={layersResetDisabled}
+ data-testid="reset-layers"
+ className="shrink-0 text-xs text-muted-foreground"
+ >
+ Reset layers
+ </Button>
+ );
+
+ return (
+ <div className="flex flex-col gap-2 max-lg:sticky max-lg:top-14 max-lg:z-10 max-lg:bg-background max-lg:py-2">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ commitSearch();
+ }}
+ className="flex flex-col gap-2"
+ data-testid="search-form"
+ >
+ {mounted ? (
+ <QueryBuilder
+ root={draftRoot}
+ onChange={setDraftRoot}
+ leafStates={leafStates}
+ groupStates={groupStates}
+ // Compact: the submit sits on the input's own line. Full tree: it
+ // goes in the row below, exactly as before — either way there is
+ // exactly one `search-submit` in the DOM.
+ inputTrailing={compact ? submit : undefined}
+ showCompactActions={false}
+ />
+ ) : (
+ <div className="h-[5.5rem] rounded-lg border border-border bg-muted" />
+ )}
+
+ {!compact && (
+ <div className="flex items-center gap-2 flex-wrap">{submit}</div>
+ )}
+
+ {/* The chip row. Rendered OUTSIDE the `mounted` gate so the workspace
+ nav exists as plain anchors before hydration — a nav click lost to
+ the pre-hydration window is a click that goes nowhere.
+ It WRAPS rather than scrolling sideways: an `overflow-x-auto` row in
+ normal flow still widens `documentElement.scrollWidth` under Chrome's
+ mobile emulation (measured: 498px at a 390px viewport, and neither
+ `overflow-x: hidden` nor a fixed width fixed it), which is the exact
+ horizontal overflow this redesign exists to remove. The modal's
+ control strip can scroll because it is `position: fixed` and so
+ contributes nothing to the document's scroll width. */}
+ <div className="flex flex-wrap items-center gap-2 py-0.5">
+ {nav}
+ {compact && (
+ <>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={layers.addLayer}
+ data-testid="compact-add-layer"
+ className="shrink-0 text-xs text-muted-foreground"
+ >
+ <LayersIcon aria-hidden="true" />+ Add layer
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={layers.addGroup}
+ className="shrink-0 text-xs text-muted-foreground"
+ >
+ + Add group
+ </Button>
+ </>
+ )}
+ {!inlineFilters && channelOptions.length > 0 && (
+ <FiltersTrigger
+ activeCount={activeFilters}
+ onApply={commitSearch}
+ />
+ )}
+ {!inlineFilters && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleShareCurrentSearch}
+ title="Copy link encoding the current search + filters"
+ className="shrink-0 text-xs text-muted-foreground"
+ >
+ <Share2Icon aria-hidden="true" />
+ {/* sr-only still contributes to the accessible name, so the
+ button is called "Share current search" — and, once copied,
+ "Link copied!" — at every width. Five specs wait on those. */}
+ <span className="sr-only sm:not-sr-only">
+ {shareCopied ? "Link copied!" : "Share current search"}
+ </span>
+ </Button>
+ )}
+ {resetLayers}
+ </div>
+
+ {/* One status line under the bar instead of two hints competing for
+ room inside it. */}
+ {(queryDirty || filtersDirty || hasSubs) && (
+ <p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
+ {(queryDirty || filtersDirty) && (
+ <span className="text-warning">
+ Press Enter or click Search to apply
+ </span>
+ )}
+ {hasSubs && (
+ <span
+ className="text-muted-foreground"
+ title={`${liveChatTotalCount} videos have live chat`}
+ >
+ Live chat available on {liveChatTotalCount} video
+ {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
+ </span>
+ )}
+ </p>
+ )}
+ </form>
+ </div>
+ );
+}
diff --git a/common/components/WorkspaceSearchBar.tsx b/common/components/WorkspaceSearchBar.tsx
@@ -1,910 +1,31 @@
"use client";
-// The persistent search BAR: the profiles row, the query-builder form, the
-// collapsible filters, and the advanced options. It's the input half of the old
-// TranscriptSearch monolith, lifted into the workspace shell so it stays mounted
-// (and keeps its committed search) across `/` ⇄ `/ask`. All of its state comes
-// from the shared SearchSession.
-
-import { Fragment, useCallback } from "react";
-import { ChevronRightIcon } from "lucide-react";
-import {
- MISSING_STATES,
- VIDEO_STATE_LABELS,
- type VideoState,
-} from "../lib/availability";
-import { Button } from "./ui/button";
-import { Checkbox } from "./ui/checkbox";
-import QueryBuilder from "./QueryBuilder";
-import { cn } from "../lib/utils";
-import { clearAdvanced } from "./exportAdvancedStorage";
-import { ymdToInput, inputToYmd } from "../lib/ymd";
-import {
- useSearchSession,
- DEFAULT_MAX_HITS,
- DEFAULT_FETCH_CONCURRENCY,
- DEFAULT_FLUSH_INTERVAL_MS,
-} from "./SearchSessionContext";
-import { useSearchData } from "./SearchDataContext";
-
-export default function WorkspaceSearchBar() {
- const {
- // Profiles row
- profiles,
- activeProfileName,
- profileDirty,
- shareCopied,
- handleLoadProfile,
- handleSaveProfile,
- handleSaveAsProfile,
- handleRenameProfile,
- handleDeleteProfile,
- handleRevertProfile,
- handleShareCurrentSearch,
- // Query builder / commit
- commitSearch,
- mounted,
- draftRoot,
- setDraftRoot,
- leafStates,
- groupStates,
- queryDirty,
- filtersDirty,
- handleResetLayers,
- layersResetDisabled,
- hasSubs,
- liveChatTotalCount,
- // Filters
- channelOptions,
- filtersCollapsed,
- toggleFiltersCollapsed,
- selectedChannelNames,
- selectedChannelsLabel,
- draftExcludedChannels,
- setDraftExcludedChannels,
- handleResetChannels,
- handleResetAllFilters,
- handleResetEverything,
- channelGroupings,
- collapsedGroups,
- toggleGroupCollapsed,
- setSelected,
- channelLabelByKey,
- draftNov,
- draftNop,
- setDraftNop,
- setDraftNov,
- draftNol,
- setDraftNol,
- draftNaa,
- setDraftNaa,
- draftNar,
- setDraftNar,
- draftStates,
- setDraftStates,
- draftDateFrom,
- setDraftDateFrom,
- draftDateTo,
- setDraftDateTo,
- // Advanced options
- hitBatchValue,
- setHitBatchValue,
- hitBatchUnlimited,
- setHitBatchUnlimited,
- fetchConcurrency,
- setFetchConcurrency,
- flushIntervalMs,
- setFlushIntervalMs,
- setHitLimit,
- } = useSearchSession();
-
- // Only offer the Posts type toggle on a site that actually ships a posts
- // corpus, so a pure-video deployment's filter row is unchanged.
- const { postsManifest } = useSearchData();
- const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0;
-
- const toggleState = useCallback(
- (state: VideoState, keep: boolean) => {
- setDraftStates((prev) => {
- const next = new Set(prev);
- if (keep) next.add(state);
- else next.delete(state);
- return next;
- });
- },
- [setDraftStates],
- );
- const setAllMissing = useCallback(
- (keep: boolean) => {
- setDraftStates((prev) => {
- const next = new Set(prev);
- for (const s of MISSING_STATES) {
- if (keep) next.add(s);
- else next.delete(s);
- }
- return next;
- });
- },
- [setDraftStates],
- );
- const allMissingKept = MISSING_STATES.every((s) => draftStates.has(s));
- const someMissingKept = MISSING_STATES.some((s) => draftStates.has(s));
-
- return (
- <div className="flex flex-col gap-6">
- <ProfilesRow
- profileNames={Object.keys(profiles).sort()}
- activeProfileName={activeProfileName}
- profileDirty={profileDirty}
- shareCopied={shareCopied}
- onSelect={handleLoadProfile}
- onSave={handleSaveProfile}
- onSaveAs={handleSaveAsProfile}
- onRename={handleRenameProfile}
- onDelete={handleDeleteProfile}
- onRevert={handleRevertProfile}
- onShareCurrentSearch={handleShareCurrentSearch}
- />
- <form
- onSubmit={(e) => {
- e.preventDefault();
- commitSearch();
- }}
- className="flex flex-col gap-3"
- data-testid="search-form"
- >
- {mounted ? (
- <QueryBuilder
- root={draftRoot}
- onChange={setDraftRoot}
- leafStates={leafStates}
- groupStates={groupStates}
- />
- ) : (
- <div className="h-12 rounded-lg border border-border bg-muted" />
- )}
- <div className="flex items-center gap-2 flex-wrap">
- <Button
- type="submit"
- data-testid="search-submit"
- data-dirty={queryDirty || filtersDirty ? "true" : "false"}
- variant={queryDirty || filtersDirty ? "default" : "outline"}
- className="shrink-0"
- >
- Search
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetLayers}
- disabled={layersResetDisabled}
- data-testid="reset-layers"
- className="text-xs text-muted-foreground"
- >
- Reset layers
- </Button>
- {(queryDirty || filtersDirty) && (
- <span className="text-xs text-warning">
- Press Enter or click Search to apply
- </span>
- )}
- {hasSubs && (
- <span
- className="text-xs text-muted-foreground ml-auto"
- title={`${liveChatTotalCount} videos have live chat`}
- >
- Live chat available on {liveChatTotalCount} video
- {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
- </span>
- )}
- </div>
- </form>
-
- {channelOptions.length > 0 && (
- <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-muted-foreground"
- >
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Filters
- </span>
- <span className="text-xs text-muted-foreground">
- {selectedChannelNames.length} of {channelOptions.length} channel
- {channelOptions.length === 1 ? "" : "s"}
- </span>
- {filtersCollapsed && selectedChannelNames.length > 0 && (
- <span
- className="text-xs text-muted-foreground 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-muted-foreground">
- {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-muted-foreground">
- Channels
- </span>
- <ChannelAllToggle
- channelOptions={channelOptions}
- draftExcludedChannels={draftExcludedChannels}
- setDraftExcludedChannels={setDraftExcludedChannels}
- />
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetChannels}
- className="text-xs text-muted-foreground"
- >
- Reset channels
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetAllFilters}
- className="text-xs text-muted-foreground"
- >
- Reset all filters
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetEverything}
- data-testid="reset-everything"
- className="text-xs text-muted-foreground"
- >
- Reset everything
- </Button>
- </div>
- <div className="flex flex-wrap items-start gap-1.5">
- {channelGroupings.map(({ group, channels }) => {
- if (group.inline) {
- // Inline group: no collapsible box — each member channel
- // renders as its own loose chip flowing among the group
- // chips, styled like a collapsed group chip.
- return (
- <Fragment key={group.id}>
- {channels.map((key) => {
- const label = channelLabelByKey.get(key) ?? key;
- return (
- <label
- key={key}
- title={label}
- data-testid="inline-channel-chip"
- className="flex items-center gap-2 rounded border border-border bg-card/60 px-2.5 py-1.5 text-sm cursor-pointer select-none min-w-0 max-w-full w-full sm:w-auto sm:min-w-48"
- >
- <span className="flex items-center shrink-0">
- <Checkbox
- checked={!draftExcludedChannels.has(key)}
- onCheckedChange={(value) =>
- setSelected([key], value === true)
- }
- />
- </span>
- <span className="truncate">{label}</span>
- </label>
- );
- })}
- </Fragment>
- );
- }
- 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}
- className={cn(
- "rounded border border-border bg-card/60 min-w-0 max-w-full w-full",
- !isOpen && "sm:w-auto sm:min-w-48",
- )}
- >
- <summary
- onClick={(e) => {
- // Drive the open state from React rather than the
- // browser's default toggle action — same rationale
- // as the outer Filters summary above.
- e.preventDefault();
- toggleGroupCollapsed(group.id, !isOpen);
- }}
- className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm min-w-0"
- title={!isOpen ? group.description : undefined}
- >
- <ChevronRightIcon
- aria-hidden="true"
- className={cn(
- "size-3.5 shrink-0 text-muted-foreground transition-transform",
- isOpen && "rotate-90",
- )}
- />
- <span
- onClick={(e) => {
- // Isolate checkbox clicks from the summary: no
- // expand/collapse, just the group bulk-toggle.
- e.preventDefault();
- e.stopPropagation();
- }}
- className="flex items-center shrink-0"
- >
- <Checkbox
- checked={
- selectedCount === 0
- ? false
- : selectedCount === channels.length
- ? true
- : "indeterminate"
- }
- onCheckedChange={(value) =>
- setSelected(channels, value === true)
- }
- aria-label={`Select all in ${group.name || "group"}`}
- />
- </span>
- {group.name && (
- <span
- className="font-medium text-foreground flex items-center gap-1.5 min-w-0"
- title={group.name}
- >
- {group.accent && (
- <span
- aria-hidden="true"
- className="inline-block size-2 shrink-0 rounded-full"
- style={{ background: group.accent }}
- />
- )}
- <span className="truncate">{group.name}</span>
- </span>
- )}
- <span
- className={cn(
- "text-xs text-muted-foreground shrink-0",
- !isOpen && "ml-auto",
- )}
- >
- {selectedCount}/{channels.length}
- <span className="sr-only"> selected</span>
- </span>
- {isOpen && (
- <span className="ml-auto flex items-center gap-2">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, true);
- }}
- className="text-xs text-muted-foreground"
- >
- All
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, false);
- }}
- className="text-xs text-muted-foreground"
- >
- None
- </Button>
- </span>
- )}
- </summary>
- {group.description && (
- <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
- {group.description}
- </div>
- )}
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2">
- {channels.map((key) => {
- const checked = !draftExcludedChannels.has(key);
- return (
- <label
- key={key}
- className="flex items-center gap-1.5 select-none"
- >
- <Checkbox
- checked={checked}
- onCheckedChange={(value) => {
- setDraftExcludedChannels((prev) => {
- const next = new Set(prev);
- if (value === true) next.delete(key);
- else next.add(key);
- return next;
- });
- }}
- />
- {channelLabelByKey.get(key) ?? key}
- </label>
- );
- })}
- </div>
- </details>
- );
- })}
- </div>
- </div>
- )}
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Type
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNov}
- onCheckedChange={(value) => setDraftNov(value !== true)}
- />
- Videos
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNol}
- onCheckedChange={(value) => setDraftNol(value !== true)}
- />
- Livestreams
- </label>
- {/* The social-post corpus is a third media kind, not a video
- sub-type — without its own toggle the video/livestream pair
- would silently drop every post. Only offered when the site
- actually ships posts. */}
- {hasPostsCorpus && (
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNop}
- onCheckedChange={(value) => setDraftNop(value !== true)}
- />
- Posts
- </label>
- )}
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Audience
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNaa}
- onCheckedChange={(value) => setDraftNaa(value !== true)}
- />
- All ages
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNar}
- onCheckedChange={(value) => setDraftNar(value !== true)}
- />
- Age-restricted
- </label>
- </div>
- {/* Availability is one enum, not three independent booleans:
- "Missing" is an umbrella over the five ways a video can leave
- its channel listing, so it renders as a parent with the leaves
- nested under it. Toggling the parent sets or clears all five;
- it goes indeterminate when only some are kept. */}
- <div className="flex flex-wrap items-start gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground pt-0.5">
- Availability
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- data-testid="av-available"
- checked={draftStates.has("available")}
- onCheckedChange={(value) =>
- toggleState("available", value === true)
- }
- />
- Available
- </label>
- <div className="flex flex-col gap-1">
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- data-testid="av-missing"
- checked={
- allMissingKept
- ? true
- : someMissingKept
- ? "indeterminate"
- : false
- }
- onCheckedChange={(value) => setAllMissing(value === true)}
- />
- Missing
- </label>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-5">
- {MISSING_STATES.map((state) => (
- <label
- key={state}
- className="flex items-center gap-1.5 select-none"
- >
- <Checkbox
- data-testid={`av-${state}`}
- checked={draftStates.has(state)}
- onCheckedChange={(value) =>
- toggleState(state, value === true)
- }
- />
- {VIDEO_STATE_LABELS[state]}
- </label>
- ))}
- </div>
- </div>
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Date
- </span>
- <label className="flex items-center gap-1.5 select-none">
- From
- <input
- type="date"
- data-testid="date-from"
- value={ymdToInput(draftDateFrom)}
- onChange={(e) =>
- setDraftDateFrom(inputToYmd(e.target.value) ?? "")
- }
- className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
- />
- </label>
- <label className="flex items-center gap-1.5 select-none">
- To
- <input
- type="date"
- data-testid="date-to"
- value={ymdToInput(draftDateTo)}
- onChange={(e) =>
- setDraftDateTo(inputToYmd(e.target.value) ?? "")
- }
- className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
- />
- </label>
- </div>
- </div>
- </details>
- )}
-
- <details className="-mt-3 text-sm text-muted-foreground">
- <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
- Advanced options
- </summary>
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60">
- <LimitField
- label="Max hits per batch"
- hint="Pause after this many matches; “Show more” adds another batch."
- value={hitBatchValue}
- unlimited={hitBatchUnlimited}
- defaultValue={DEFAULT_MAX_HITS}
- onValueChange={setHitBatchValue}
- onUnlimitedChange={setHitBatchUnlimited}
- />
- <NumberField
- label="Fetch concurrency"
- hint="Parallel transcript downloads while searching. Applies on next search."
- value={fetchConcurrency}
- defaultValue={DEFAULT_FETCH_CONCURRENCY}
- min={1}
- max={32}
- onChange={setFetchConcurrency}
- />
- <NumberField
- label="Flush interval (ms)"
- hint="UI update cadence during search. Applies on next search."
- value={flushIntervalMs}
- defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
- min={16}
- max={2000}
- onChange={setFlushIntervalMs}
- />
- <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHitBatchUnlimited(true);
- setHitLimit(Infinity);
- }}
- className="text-xs"
- >
- Search through all transcripts
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHitBatchValue(DEFAULT_MAX_HITS);
- setHitBatchUnlimited(false);
- setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
- setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
- clearAdvanced();
- }}
- className="text-xs"
- >
- Reset to defaults
- </Button>
- </div>
- </div>
- </details>
- </div>
- );
-}
-
-function ProfilesRow({
- profileNames,
- activeProfileName,
- profileDirty,
- shareCopied,
- onSelect,
- onSave,
- onSaveAs,
- onRename,
- onDelete,
- onRevert,
- onShareCurrentSearch,
-}: {
- profileNames: string[];
- activeProfileName: string | null;
- profileDirty: boolean;
- shareCopied: boolean;
- onSelect: (name: string) => void;
- onSave: () => void;
- onSaveAs: () => void;
- onRename: () => void;
- onDelete: () => void;
- onRevert: () => void;
- onShareCurrentSearch: () => void;
-}) {
- const showRevert = activeProfileName != null && profileDirty;
- return (
- <div
- className="flex flex-wrap items-center gap-x-3 gap-y-1"
- data-testid="profiles-row"
- >
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Profile
- </span>
- <select
- value={activeProfileName ?? ""}
- onChange={(e) => onSelect(e.target.value)}
- data-testid="profile-select"
- className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm"
- >
- <option value="">(unsaved)</option>
- {profileNames.map((n) => (
- <option key={n} value={n}>
- {n}
- </option>
- ))}
- </select>
- {profileDirty && (
- <span
- role="status"
- aria-label="profile has unsaved changes"
- data-testid="profile-dirty-dot"
- className="text-base leading-none text-warning"
- >
- •
- </span>
- )}
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onSave}
- disabled={!activeProfileName || !profileDirty}
- className="text-xs text-muted-foreground"
- >
- Save
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onSaveAs}
- className="text-xs text-muted-foreground"
- >
- Save as…
- </Button>
- {showRevert && (
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onRevert}
- data-testid="profile-revert"
- className="text-xs text-muted-foreground"
- >
- Revert
- </Button>
- )}
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onRename}
- disabled={!activeProfileName}
- className="text-xs text-muted-foreground"
- >
- Rename
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onDelete}
- disabled={!activeProfileName}
- className="text-xs text-destructive"
- >
- Delete
- </Button>
- <div className="ml-auto flex items-center gap-x-3">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onShareCurrentSearch}
- title={
- shareCopied
- ? "Copied!"
- : "Copy link encoding the current search + filters"
- }
- className="text-xs text-muted-foreground"
- >
- {shareCopied ? "Link copied!" : "Share current search"}
- </Button>
- </div>
- </div>
- );
-}
-
-function ChannelAllToggle({
- channelOptions,
- draftExcludedChannels,
- setDraftExcludedChannels,
-}: {
- channelOptions: string[];
- draftExcludedChannels: Set<string>;
- setDraftExcludedChannels: (next: Set<string>) => void;
-}) {
- const allChecked = draftExcludedChannels.size === 0;
- return (
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={allChecked}
- onCheckedChange={() => {
- setDraftExcludedChannels(
- allChecked ? new Set(channelOptions) : new Set(),
- );
- }}
- />
- All
- </label>
- );
-}
-
-function NumberField({
- label,
- hint,
- value,
- defaultValue,
- min,
- max,
- onChange,
- disabled,
-}: {
- label: string;
- hint?: string;
- value: number;
- defaultValue: number;
- min: number;
- max: number;
- onChange: (n: number) => void;
- disabled?: boolean;
-}) {
- return (
- <label className="flex flex-col gap-1">
- <span className="text-xs font-medium text-foreground">
- {label}
- </span>
- <input
- type="number"
- min={min}
- max={max}
- value={Number.isFinite(value) ? value : ""}
- placeholder={String(defaultValue)}
- disabled={disabled}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onChange(defaultValue);
- return;
- }
- const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
- if (Number.isFinite(n)) onChange(n);
- }}
- className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
- </label>
- );
-}
-
-function LimitField({
- label,
- hint,
- value,
- unlimited,
- defaultValue,
- onValueChange,
- onUnlimitedChange,
+// The persistent search bar, as a composition of two pieces:
+//
+// • SearchBar — the query box, the submit, and one chip row. The standard
+// path, and the only thing a first-time visitor must read.
+// • FiltersPanel — the profile row and every filter, placed by
+// FiltersContainer (inline from xl, a sheet below it).
+//
+// It stays mounted in the workspace shell, so the committed search survives
+// `/` ⇄ `/ask`. All of its state comes from the shared SearchSession.
+
+import type { ReactNode } from "react";
+import SearchBar from "./SearchBar";
+import { FiltersSection } from "./FiltersContainer";
+
+export default function WorkspaceSearchBar({
+ // The [Search | Chat | Split] control, when the caller has one. It is an
+ // export-app component and this lives in common/, so it arrives as a slot —
+ // and it belongs beside the search box, not below the filters.
+ nav,
}: {
- label: string;
- hint?: string;
- value: number;
- unlimited: boolean;
- defaultValue: number;
- onValueChange: (n: number) => void;
- onUnlimitedChange: (b: boolean) => void;
+ nav?: ReactNode;
}) {
return (
- <div className="flex flex-col gap-1">
- <div className="flex items-baseline justify-between gap-2">
- <span className="text-xs font-medium text-foreground">
- {label}
- </span>
- <label className="flex items-center gap-1 text-xs select-none">
- <Checkbox
- checked={unlimited}
- onCheckedChange={(value) => onUnlimitedChange(value === true)}
- />
- No limit
- </label>
- </div>
- <input
- type="number"
- aria-label={label}
- min={1}
- max={1_000_000}
- value={unlimited ? "" : value}
- placeholder={unlimited ? "∞" : String(defaultValue)}
- disabled={unlimited}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onValueChange(defaultValue);
- return;
- }
- const n = Math.max(1, Math.floor(Number(raw)));
- if (Number.isFinite(n)) onValueChange(n);
- }}
- className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ <div className="flex flex-col gap-4">
+ <SearchBar nav={nav} />
+ <FiltersSection />
</div>
);
}
diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx
@@ -16,7 +16,8 @@ import PostModal from "yt-dlp-transcript-common/components/PostModal";
import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
import WorkspaceSearchBar from "yt-dlp-transcript-common/components/WorkspaceSearchBar";
-import WorkspaceView from "./WorkspaceView";
+import WorkspaceView, { WorkspaceViewProvider } from "./WorkspaceView";
+import WorkspaceNav from "./WorkspaceNav";
export default function SiteWorkspace({
children,
@@ -27,13 +28,16 @@ export default function SiteWorkspace({
<PlayerProvider>
<SingleSiteDataProvider>
<SearchSessionProvider>
- <div className="flex flex-col gap-6">
- <WorkspaceSearchBar />
- {/* Both panes (results + chat) mount once inside WorkspaceView; the
- [Search|Chat|Split] control governs which show. The routed
- `children` is each route's intro chrome, slotted above its pane. */}
- <WorkspaceView>{children}</WorkspaceView>
- </div>
+ {/* The view provider wraps BOTH: the [Search|Chat|Split] control is
+ slotted into the bar's chip row, and the panes it governs live in
+ WorkspaceView. The routed `children` is each route's intro
+ chrome, slotted above its pane. */}
+ <WorkspaceViewProvider>
+ <div className="flex flex-col gap-6">
+ <WorkspaceSearchBar nav={<WorkspaceNav />} />
+ <WorkspaceView>{children}</WorkspaceView>
+ </div>
+ </WorkspaceViewProvider>
</SearchSessionProvider>
</SingleSiteDataProvider>
<TranscriptModal />
diff --git a/export/app/(workspace)/WorkspaceNav.tsx b/export/app/(workspace)/WorkspaceNav.tsx
@@ -10,26 +10,15 @@
import Link from "next/link";
import { ColumnsIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
-import type { ViewMode } from "./WorkspaceView";
+import { useWorkspaceView } from "./WorkspaceView";
-export default function WorkspaceNav({
- onAsk,
- view,
- wide,
- onPickSingle,
- onPickSplit,
-}: {
- // Whether the current route is /ask (drives which single-pane link is active).
- onAsk: boolean;
- // The current (possibly split) view.
- view: ViewMode;
- // ≥ lg — Split is only offered here.
- wide: boolean;
- // Picking a single-pane view (navigates + clears any split).
- onPickSingle: (v: "search" | "chat") => void;
- // Picking the side-by-side view (stays on the current route).
- onPickSplit: () => void;
-}) {
+// Reads its state from WorkspaceViewProvider rather than taking props, because
+// it is rendered in the search bar (a common/ component that cannot import it)
+// while the panes it governs are rendered in WorkspaceView.
+export default function WorkspaceNav() {
+ const { onAsk, view, wide, setSplit } = useWorkspaceView();
+ const onPickSingle = () => setSplit(false);
+ const onPickSplit = () => setSplit(true);
const split = view === "split";
const base =
"inline-flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors";
@@ -42,12 +31,12 @@ export default function WorkspaceNav({
role="group"
aria-label="Search, chat, or split view"
data-testid="workspace-nav"
- className="inline-flex self-start overflow-hidden rounded-md border border-border bg-muted text-sm"
+ className="inline-flex shrink-0 self-start overflow-hidden rounded-md border border-border bg-muted text-sm"
>
<Link
href="/"
aria-current={!onAsk && !split ? "page" : undefined}
- onClick={() => onPickSingle("search")}
+ onClick={onPickSingle}
className={`${base} ${!split && !onAsk ? active : idle}`}
>
<SearchIcon className="size-3.5" /> Search
@@ -55,7 +44,7 @@ export default function WorkspaceNav({
<Link
href="/ask/"
aria-current={onAsk && !split ? "page" : undefined}
- onClick={() => onPickSingle("chat")}
+ onClick={onPickSingle}
className={`${base} ${!split && onAsk ? active : idle}`}
>
<MessageSquareIcon className="size-3.5" /> Chat
diff --git a/export/app/(workspace)/WorkspaceView.tsx b/export/app/(workspace)/WorkspaceView.tsx
@@ -16,18 +16,42 @@
// header on `/`, the "Ask a question" header on `/ask`); it's slotted above the
// pane it belongs to.
-import { useEffect, useState, type ReactNode } from "react";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import { usePathname } from "next/navigation";
import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
import SearchResults from "yt-dlp-transcript-common/components/SearchResults";
import AskChat from "../ask/AskChat";
-import WorkspaceNav from "./WorkspaceNav";
export type ViewMode = "search" | "chat" | "split";
const SPLIT_KEY = "ytdlp-tb:workspace-split";
-export default function WorkspaceView({ children }: { children: ReactNode }) {
+type WorkspaceViewValue = {
+ onAsk: boolean;
+ view: ViewMode;
+ wide: boolean;
+ setSplit: (on: boolean) => void;
+};
+
+const Ctx = createContext<WorkspaceViewValue | null>(null);
+
+export function useWorkspaceView(): WorkspaceViewValue {
+ const v = useContext(Ctx);
+ if (!v) throw new Error("useWorkspaceView outside WorkspaceViewProvider");
+ return v;
+}
+
+// The view state lives ABOVE both the search bar and the panes, because the
+// [Search | Chat | Split] control now sits in the bar's chip row — beside the
+// search box, where the choice is made — while the panes it governs are here.
+export function WorkspaceViewProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const onAsk = pathname?.startsWith("/ask") ?? false;
@@ -48,19 +72,33 @@ export default function WorkspaceView({ children }: { children: ReactNode }) {
/* eslint-enable react-hooks/set-state-in-effect */
}, []);
- const setSplit = (on: boolean) => {
- setSplitOn(on);
- try {
- localStorage.setItem(SPLIT_KEY, on ? "1" : "0");
- } catch {
- /* ignore */
- }
- };
-
// Split only takes effect on a wide viewport; otherwise the view follows the
// route (the two-state tab degradation).
const view: ViewMode =
splitOn && wide ? "split" : onAsk ? "chat" : "search";
+
+ const value = useMemo<WorkspaceViewValue>(
+ () => ({
+ onAsk,
+ view,
+ wide,
+ setSplit: (on: boolean) => {
+ setSplitOn(on);
+ try {
+ localStorage.setItem(SPLIT_KEY, on ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ },
+ }),
+ [onAsk, view, wide],
+ );
+
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+}
+
+export default function WorkspaceView({ children }: { children: ReactNode }) {
+ const { onAsk, view } = useWorkspaceView();
const split = view === "split";
const showSearch = split || view === "search";
const showChat = split || view === "chat";
@@ -79,14 +117,6 @@ export default function WorkspaceView({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col gap-6">
- <WorkspaceNav
- onAsk={onAsk}
- view={view}
- wide={wide}
- onPickSingle={() => setSplit(false)}
- onPickSplit={() => setSplit(true)}
- />
-
<div
className={
split ? "grid gap-6 lg:grid-cols-2 lg:items-start" : "block"
diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts
@@ -111,6 +111,28 @@ export async function installChartRoutes(page: Page) {
});
}
+// Make the filters reachable. From xl they are an inline block under the search
+// bar and this is a no-op; below xl they live in a sheet behind the "Filters"
+// chip, so a spec that asserts on a filter control has to open it first.
+export async function openFilters(page: Page) {
+ // The bar only decides where the panel goes once it has hydrated and read
+ // the viewport, so wait for the builder before asking which shape it took.
+ await page.getByTestId("query-builder").waitFor();
+ const panel = page.getByTestId("filters-panel");
+ const trigger = page.getByTestId("filters-trigger");
+ await panel.or(trigger).first().waitFor();
+ if ((await panel.count()) === 0) {
+ await trigger.click();
+ await panel.waitFor();
+ }
+ // Inline placement: the panel is a <details> and may be collapsed.
+ const profiles = panel.getByTestId("profiles-row");
+ if (!(await profiles.isVisible())) {
+ await panel.locator("summary").first().click();
+ }
+ await profiles.waitFor();
+}
+
export async function urlParams(page: Page): Promise<URLSearchParams> {
const search = await page.evaluate(() => window.location.search);
return new URLSearchParams(search);
diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts
@@ -1,7 +1,8 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { openFilters } from "./helpers";
// Self-contained fixtures exercising inline channel groups
-// (common/components/WorkspaceSearchBar.tsx): a group flagged `inline: true`
+// (common/components/FiltersPanel.tsx): a group flagged `inline: true`
// renders its member channels as loose individual chips — checkbox + name,
// no collapsible box — flowing among the normal group chips.
@@ -219,6 +220,9 @@ test.describe("inline channel chips on mobile", () => {
await installRoutes(page);
await page.goto("/");
await waitForHydration(page);
+ // Below xl the filters live in a sheet behind the "Filters" chip, so the
+ // chips have to be opened before they can be measured.
+ await openFilters(page);
});
test("chips stack one per line at phone widths", async ({ page }) => {
diff --git a/export/e2e/profile-row.spec.ts b/export/e2e/profile-row.spec.ts
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { CHANNEL } from "./fixtures/data";
-import { installRoutes } from "./helpers";
+import { installRoutes, openFilters } from "./helpers";
const STORAGE_KEY = "ytdlp-tb:export-filters";
@@ -65,22 +65,23 @@ test.describe("search UI — profile row + reset refinements", () => {
await expect(reset).toBeDisabled();
});
- test("Profile row renders above the QueryBuilder, even with one channel", async ({
+ test("Profile row lives in the filters panel, even with one channel", async ({
page,
}) => {
await page.goto("/");
- const profilesRow = page.getByTestId("profiles-row");
- const builder = page.getByTestId("query-builder");
+ // A profile IS a saved filter+query set, so it sits inside the filters
+ // panel rather than as a third row above the search box. Its controls are
+ // reachable wherever the panel is placed (inline from xl, a sheet below).
+ await openFilters(page);
+ const panel = page.getByTestId("filters-panel");
+ const profilesRow = panel.getByTestId("profiles-row");
await expect(profilesRow).toBeVisible();
- await expect(builder).toBeVisible();
- const profileBox = await profilesRow.boundingBox();
- const builderBox = await builder.boundingBox();
- if (!profileBox || !builderBox) throw new Error("missing bounding box");
- expect(profileBox.y).toBeLessThan(builderBox.y);
+ await expect(page.getByTestId("query-builder")).toBeVisible();
});
test("Share button lives inside the Profile row", async ({ page }) => {
await page.goto("/");
+ await openFilters(page);
const profilesRow = page.getByTestId("profiles-row");
const shareInRow = profilesRow.getByRole("button", {
name: "Share current search",