"use client"; // Single-leaf editor card for the composite search builder. // // Renders one row when the parent says "compact mode" (the simple-search // case: root is one AND group with one leaf, no negation), otherwise a // boxed card with all controls visible. The card shows its layer swatch, // 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, type ReactNode } from "react"; import type { LeafNode, LayerScope } from "../lib/searchQuery"; import type { LeafState } from "../lib/searchEval"; import { matchAliases } from "../lib/searchAliases"; import { useSearchData } from "./SearchDataContext"; import { LayerSwatch, swatchSoftBgStyle } from "./LayerSwatch"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Checkbox } from "./ui/checkbox"; import { cn } from "../lib/utils"; const negateCheckboxClass = "border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive data-[state=checked]:text-destructive-foreground"; 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; onChange: (next: LeafNode) => void; onDelete: () => void; onWrapInGroup?: () => void; }; // LABELS ONLY. The `tags` scope searches yt-dlp KEYWORDS from // metadata.info.json (TranscriptSummary.tags) and is shown as "Keywords", // because "Tags" now means the operator's curated per-video vocabulary // (lib/curatedTags.ts) — the chip row in the filter panel. The code token, the // URL and the MCP `scopes` enum stay `"tags"`: renaming those would break every // saved query and share link to fix a word on a screen. // Exported because the result list labels its per-leaf sections with the same // words (SearchResults.tsx): a reader picks "Keywords" in the builder and has // to find "Keywords" over the hits it produced, so there is one table, not two // that drift. export const SCOPE_LABELS: Record = { transcripts: "Transcripts", chat: "Live chat", posts: "Posts", metadata: "Title / channel", description: "Description", tags: "Keywords", }; export default function QueryLeafView({ leaf, state, compact = false, inputTrailing, canDelete, canNegate = true, onChange, onDelete, onWrapInGroup, }: Props) { const update = (patch: Partial) => onChange({ ...leaf, ...patch }); const regexInvalid = leaf.useRegex && leaf.query.trim() !== "" && !isValidRegex(leaf.query); // Known-alias suggestions. A curated concept ("loli") that a typed term // matches offers a better regex the user can Apply — never forced. In regex // mode we only offer when the whole query is exactly a trigger (a bare // literal), so a hand-written pattern — or an already-applied suggestion — // isn't second-guessed. Dismissals are scoped to the current query text so a // new term re-offers. See ../lib/searchAliases. const { aliases } = useSearchData(); const [dismissed, setDismissed] = useState<{ q: string; ids: Set }>({ q: "", ids: new Set(), }); const activeDismissed = dismissed.q === leaf.query ? dismissed.ids : null; const suggestions = matchAliases(leaf.query, leaf.scope, aliases, { regexMode: leaf.useRegex, }).filter((a) => !activeDismissed?.has(a.id)); const dismissAlias = (id: string) => setDismissed((prev) => { const ids = prev.q === leaf.query ? new Set(prev.ids) : new Set(); ids.add(id); return { q: leaf.query, ids }; }); const applyAlias = (suggestion: string, useRegex: boolean) => update({ query: suggestion, useRegex }); const aliasSuggestions = suggestions.length > 0 ? (
{suggestions.map((a) => (
alias{" "} “{a.label}” {a.suggestion}
{a.note && (

{a.note}

)}
))}
) : null; const placeholder = leaf.scope === "metadata" ? leaf.useRegex ? "Title/channel regex..." : "Match against title or channel..." : leaf.scope === "description" ? leaf.useRegex ? "Description regex..." : "Search video descriptions..." : leaf.scope === "tags" ? leaf.useRegex ? "Keywords regex..." : "Search video keywords..." : leaf.scope === "chat" ? leaf.useRegex ? "Live chat regex..." : "Search live chat..." : leaf.useRegex ? "Regex pattern (case-insensitive)..." : "Search transcripts..."; const queryInput = ( update({ query: e.target.value })} placeholder={placeholder} data-testid={`leaf-query-${leaf.id}`} aria-invalid={regexInvalid || undefined} className={cn( // 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", )} /> ); const scopeSelect = ( ); const regexToggle = ( ); const countBadge = state ? ( → {state.slugCount} video{state.slugCount === 1 ? "" : "s"} {state.active ? "…" : ""} ) : null; const cachedBadge = state?.cached && !state.active ? ( cached ) : null; if (compact) { return (
{queryInput} {inputTrailing}
{scopeSelect} {regexToggle} {countBadge} {cachedBadge}
{aliasSuggestions}
); } return (
{queryInput} {countBadge}
{scopeSelect} {regexToggle} {canNegate && ( )} {cachedBadge}
{onWrapInGroup && ( )}
{regexInvalid && (

Invalid regex

)} {aliasSuggestions}
); } function isValidRegex(s: string): boolean { try { new RegExp(s); return true; } catch { return false; } }