"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 `
` 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, useMemo, useState } 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, SEARCH_IN_REFUSAL, DEFAULT_MAX_HITS, DEFAULT_FETCH_CONCURRENCY, DEFAULT_FLUSH_INTERVAL_MS, } from "./SearchSessionContext"; import { useSearchData } from "./SearchDataContext"; import { groupPublishedTags, selectableTags, } from "../lib/publishedTags"; import type { PublishedTag } from "../lib/curatedTags"; 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, draftNotr, setDraftNotr, draftLc, setDraftLc, draftSearchInEmpty, hasPostsCorpus, hasSubs, setDraftNov, draftNol, setDraftNol, draftNaa, setDraftNaa, draftNar, setDraftNar, draftStates, setDraftStates, draftDateFrom, setDraftDateFrom, draftDateTo, setDraftDateTo, hitBatchValue, setHitBatchValue, hitBatchUnlimited, setHitBatchUnlimited, fetchConcurrency, setFetchConcurrency, flushIntervalMs, setFlushIntervalMs, setHitLimit, } = useSearchSession(); 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 (
{ // 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
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", )} > Filters {channelOptions.length > 0 && ( {selectedChannelNames.length} of {channelOptions.length} channel {channelOptions.length === 1 ? "" : "s"} )} {filtersCollapsed && selectedChannelNames.length > 0 && ( {selectedChannelsLabel} )}
{channelOptions.length > 0 && (
{channelOptions.length > 1 && (
Channels
{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 ( {channels.map((key) => { const label = channelLabelByKey.get(key) ?? key; return ( ); })} ); } const isOpen = !collapsedGroups.has(group.id); const selectedCount = channels.reduce( (n, name) => draftExcludedChannels.has(name) ? n : n + 1, 0, ); return (
{ // 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} > {group.description && (
{group.description}
)}
{channels.map((key) => { const checked = !draftExcludedChannels.has(key); return ( ); })}
); })}
)} {/* What a plain query reads — the default "transcripts" leaf, not a leaf asked for by name in the builder. Posts is offered only where the site ships posts and Live chat only where it has live chat; with nothing ticked Search is refused and this row says so. Type below still says which records are listed. */}
Search in {hasPostsCorpus && ( )} {hasSubs && ( )} {/* Always mounted, empty unless Search is refused: a live region is announced when its content changes, which several screen readers miss for a region that appears with its text. A polite live region rather than role="status": this panel is always mounted from xl, and a page-wide second "status" would be a second answer to `getByRole("status")` (modal-digest.spec). The Search button is described by the bar's own copy of these words (this panel may be a closed sheet). */} {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""}
Type
Audience
{/* 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. */}
Availability
{MISSING_STATES.map((state) => ( ))}
Date
)} {/* Throughput knobs: nobody sets these on a first visit, and on a phone they were four more rows between the query and the results. */}
Advanced options
); } function ProfilesRow({ profileNames, activeProfileName, profileDirty, shareCopied, onSelect, onSave, onSaveAs, onRename, onDelete, onRevert, onShareCurrentSearch, saveDisabled, }: { 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; // A save commits the draft, so it is refused when Search is ("Search in" // has nothing ticked). saveDisabled: boolean; }) { const showRevert = activeProfileName != null && profileDirty; return (
Profile {profileDirty && ( • )} {showRevert && ( )}
); } // The curated-tag chip row (lib/curatedTags.ts) — "Eva: Collab · In chat · // Discussed" beside the channel-group chips. // // Built ONLY from this site's published /tags.json, which is the operator's // decision and the reason there is no static list anywhere in this file: a tag // defined corpus-wide but matching nothing on THIS site does not appear here, // and neither does a hidden one, because compose-site never wrote it into this // site's document. The whole row is absent when the site publishes nothing — // which is every site until someone curates a tag, and every site built before // corpus spec 4. // // OR across the chips: selecting Collab and In chat is every stream with // either, never only the ones with both. A selected chip clears on click, so // the row is its own reset and needs no second control. function TagChipRow() { const { curatedTags } = useSearchData(); const { draftTags, toggleDraftTag } = useSearchSession(); // selectableTags is the belt to compose-site's braces: a stale or // hand-written document must not be able to offer a chip that finds nothing. const publishable = useMemo(() => selectableTags(curatedTags), [curatedTags]); const groups = useMemo(() => groupPublishedTags(publishable), [publishable]); // Tags the SELECTION carries that this site does not publish. A `?tg=` link // written against another site in the family, or against this one before a // rebuild, arrives with a perfectly valid id that has no chip — and without // this the Filters badge and the "N selected" line would say a filter is on // while nothing in the row is lit, and (if it is the only tag) the results // would go to zero with nothing to click off. So the selection ALWAYS has a // chip, even when the vocabulary does not have the tag. const unpublished = useMemo(() => { const known = new Set(publishable.map((t) => t.id)); return Array.from(draftTags) .filter((id) => !known.has(id)) .sort(); }, [publishable, draftTags]); // Collapse is per-group and LOCAL, unlike the channel groups' persisted // `collapsedGroups`: a tag row is a handful of chips a reader folds away to // get the results back on screen, not a filter shape worth carrying into // every future session — and a group that came back folded would hide the // vocabulary this site is built around. Every group starts open. const [collapsedTagGroups, setCollapsedTagGroups] = useState>( () => new Set(), ); const toggleTagGroup = useCallback((key: string) => { setCollapsedTagGroups((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const renderChip = (tag: PublishedTag) => { const selected = draftTags.has(tag.id); return ( ); }; if (groups.length === 0 && unpublished.length === 0) return null; return (
Tags {draftTags.size === 0 ? "any" : `${draftTags.size} selected — a video with ANY of them`}
{/* One wrapping flow, phone-first: a group is a disclosure — its label over its chips — and wraps as a unit, so a narrow viewport gets one group per line rather than a torn row, and a reader on a phone can fold a group away to get the results back on screen. */}
{groups.map((group) => { const key = group.id || "ungrouped"; const chips = group.tags.map(renderChip); // The trailing ungrouped bucket has no label, so there is nothing to // put in a summary and nothing to name what folding it away hides: // it stays a plain wrapping row. if (!group.label) { return (
{chips}
); } const isOpen = !collapsedTagGroups.has(key); // What the summary has to say when the chips are hidden: a folded // group that is narrowing the results must still admit it, or the // reader is left with a short list and no visible cause. const selectedCount = group.tags.reduce( (n, tag) => (draftTags.has(tag.id) ? n + 1 : n), 0, ); return (
{ // Drive the open state from React rather than the browser's // default toggle — same rationale as the channel group // chips above. e.preventDefault(); toggleTagGroup(key); }} className="cursor-pointer select-none flex items-center gap-1.5 py-1 text-xs text-muted-foreground min-w-0" title={ isOpen ? `Hide the ${group.label} tags` : `Show the ${group.label} tags` } >
{chips}
); })} {unpublished.length > 0 && (
{unpublished.map((id) => ( ))}
)}
); } function ChannelAllToggle({ channelOptions, draftExcludedChannels, setDraftExcludedChannels, }: { channelOptions: string[]; draftExcludedChannels: Set; setDraftExcludedChannels: (next: Set) => void; }) { const allChecked = draftExcludedChannels.size === 0; return ( ); } 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 ( ); } 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 (
{label}
{ 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 && {hint}}
); }