import { useT } from "@agent-native/core/client/i18n"; import { IconClock, IconSearch, IconX } from "@tabler/icons-react"; import React, { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { msToClock } from "@/components/player/scrubber"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { useRecordingSearch, type SearchHit } from "@/hooks/use-library"; import { cn, shortcutLabel } from "@/lib/utils"; function highlight( text: string, query: string, ): (string | React.JSX.Element)[] { if (!query) return [text]; const lower = text.toLowerCase(); const q = query.toLowerCase(); const parts: (string | React.JSX.Element)[] = []; let i = 0; while (i < text.length) { const idx = lower.indexOf(q, i); if (idx === -1) { parts.push(text.slice(i)); break; } if (idx > i) parts.push(text.slice(i, idx)); parts.push( {text.slice(idx, idx + q.length)} , ); i = idx + q.length; } return parts; } interface SearchBarProps { className?: string; side?: "top" | "right" | "bottom" | "left"; } function matchLabel(hit: SearchHit, t: ReturnType): string { switch (hit.matchType) { case "title-transcript": return t("searchBar.titleTranscript"); case "title-comment": return t("searchBar.titleComment"); case "transcript": return t("searchBar.transcript"); case "comment": return t("searchBar.comment"); default: return t("searchBar.titleOrDescription"); } } export function SearchBar({ className, side = "right" }: SearchBarProps) { const t = useT(); const navigate = useNavigate(); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const inputRef = useRef(null); const { data, isFetching } = useRecordingSearch(query); const results: SearchHit[] = data?.results ?? []; // Cmd+K / Ctrl+K and "/" global shortcuts to focus search useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); setOpen(true); } if ( e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && (e.target as HTMLElement)?.tagName?.toLowerCase() !== "input" && (e.target as HTMLElement)?.tagName?.toLowerCase() !== "textarea" && !(e.target as HTMLElement)?.isContentEditable ) { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); setOpen(true); } if (e.key === "Escape") { setOpen(false); inputRef.current?.blur(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); function pickResult(hit: SearchHit) { setOpen(false); setQuery(""); const params = new URLSearchParams(); if (typeof hit.matchMs === "number" && Number.isFinite(hit.matchMs)) { params.set("t", Math.max(0, Math.floor(hit.matchMs / 1000)).toString()); } if (hit.matchPanel) params.set("panel", hit.matchPanel); const suffix = params.toString() ? `?${params.toString()}` : ""; navigate(`/r/${hit.id}${suffix}`); } const showPopover = open && query.length >= 2; return ( { setQuery(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} placeholder={t("searchBar.placeholder")} className="w-full h-8 rounded-md border border-border bg-background ps-8 pe-12 text-xs outline-none focus:ring-2 focus:ring-primary/30" /> {query ? ( { setQuery(""); inputRef.current?.focus(); }} className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:bg-accent" > ) : ( {shortcutLabel("cmd+k")} )} e.preventDefault()} > {isFetching && results.length === 0 && ( {t("searchBar.searching")} )} {!isFetching && results.length === 0 && ( {t("searchBar.noMatchesFor")}{" "} {query} )} {results.length > 0 && ( {results.map((hit) => ( pickResult(hit)} onKeyDown={(e) => e.key === "Enter" && pickResult(hit)} className="flex items-start gap-3 p-3 hover:bg-accent cursor-pointer" > {hit.thumbnailUrl && ( // eslint-disable-next-line jsx-a11y/alt-text )} {highlight(hit.title, query)} {hit.snippet && ( {highlight(hit.snippet, query)} )} {matchLabel(hit, t)} {typeof hit.matchMs === "number" ? ( <> ยท {msToClock(hit.matchMs)} > ) : null} ))} )} ); }