Files
legal-ai/web-ui/src/components/global-search.tsx
Chaim f722fa45bd
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
feat(search): add header global search (Phase A) — cases + precedents + docs
Adds an always-visible debounced search input in the AppShell header
that fans out to three independent sources in parallel and renders
per-source result groups with their own loading/empty/error states:

- /api/search/cases (NEW): SQL ILIKE on case_number, address, parties,
  title, subject. Returns small projections, no embeddings needed.
- /api/precedent-library/search (existing): semantic over case-law
  halachot + passages.
- /api/search (existing): semantic over case documents + past decisions.

Cmd/Ctrl+K focuses the input; Esc and click-outside close the panel.
This is Phase A of the header redesign — the bar layout itself is
unchanged; row grouping + dynamic context follow in Phase B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:05:51 +00:00

341 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Search, FileText, BookOpen, FolderOpen, Loader2 } from "lucide-react";
import {
useGlobalSearch,
type CaseHit,
type DocumentHit,
} from "@/lib/api/global-search";
import type { SearchHit as PrecedentHit } from "@/lib/api/precedent-library";
const DEBOUNCE_MS = 250;
/**
* Header global search — debounced input + per-source result panel.
*
* Three independent fan-out queries (cases / precedent / documents) each
* render their own section with an own loading/empty state, so the fast
* SQL source paints immediately while the slower vector sources stream in.
*/
export function GlobalSearch() {
const [raw, setRaw] = useState("");
const [debounced, setDebounced] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
// Debounce.
useEffect(() => {
const t = setTimeout(() => setDebounced(raw), DEBOUNCE_MS);
return () => clearTimeout(t);
}, [raw]);
// Cmd/Ctrl+K — focus + select.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isModK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isModK) {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Click-outside.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("mousedown", onClick);
return () => window.removeEventListener("mousedown", onClick);
}, [open]);
// Route changes don't need a separate listener: clicking any link
// (nav, search result, in-page) triggers mousedown outside wrapperRef,
// and the click-outside effect closes the panel.
const { cases, precedent, documents, isQueryReady, anyLoading } =
useGlobalSearch(debounced);
const showPanel = open && isQueryReady;
const hasAnyResults = useMemo(() => {
return (
(cases.data?.items.length ?? 0) > 0 ||
(precedent.data?.items.length ?? 0) > 0 ||
(documents.data?.length ?? 0) > 0
);
}, [cases.data, precedent.data, documents.data]);
return (
<div ref={wrapperRef} className="relative w-full max-w-[460px]">
<div className="relative">
<Search
className="absolute end-3 top-1/2 -translate-y-1/2 size-4 text-parchment/50 pointer-events-none"
aria-hidden="true"
/>
<input
ref={inputRef}
type="search"
value={raw}
onChange={(e) => {
setRaw(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
}}
placeholder="חפש תיק, פסיקה, או מסמך…"
aria-label="חיפוש גלובלי"
autoComplete="off"
spellCheck={false}
className="
w-full ps-3 pe-10 py-2 rounded-md
bg-navy-soft/60 border border-parchment/15
text-sm text-parchment placeholder:text-parchment/40
focus:outline-none focus:ring-2 focus:ring-gold/60 focus:border-transparent
transition-colors
"
/>
<kbd
className="
absolute end-9 top-1/2 -translate-y-1/2
text-[10px] font-mono text-parchment/40
border border-parchment/20 rounded px-1 py-0.5
pointer-events-none select-none
hidden md:inline-block
"
aria-hidden="true"
>
K
</kbd>
</div>
{showPanel && (
<div
id={listboxId}
role="listbox"
aria-label="תוצאות חיפוש"
className="
absolute top-full inset-x-0 mt-2 z-50
rounded-lg bg-popover text-popover-foreground
shadow-xl ring-1 ring-foreground/10
max-h-[70vh] overflow-y-auto
"
dir="rtl"
>
{anyLoading && !hasAnyResults && (
<div className="px-4 py-6 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
מחפש
</div>
)}
<ResultGroup
title="תיקים"
icon={<FolderOpen className="size-4" aria-hidden="true" />}
isLoading={cases.isLoading}
isError={cases.isError}
count={cases.data?.count}
>
{cases.data?.items.map((hit) => (
<CaseRow key={hit.case_number} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
<ResultGroup
title="ספריית פסיקה"
icon={<BookOpen className="size-4" aria-hidden="true" />}
isLoading={precedent.isLoading}
isError={precedent.isError}
count={precedent.data?.count}
seeMoreHref={`/precedents?q=${encodeURIComponent(debounced)}`}
>
{precedent.data?.items.map((hit, i) => (
<PrecedentRow key={`${hit.case_law_id}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
<ResultGroup
title="מסמכי תיקים והחלטות"
icon={<FileText className="size-4" aria-hidden="true" />}
isLoading={documents.isLoading}
isError={documents.isError}
count={documents.data?.length}
>
{documents.data?.map((hit, i) => (
<DocumentRow key={`${hit.case_number}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
{!anyLoading && !hasAnyResults && (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
לא נמצא כלום עבור <span className="font-mono">{debounced}</span>.
<br />
נסה מילים נרדפות או חפש לפי מספר תיק.
</div>
)}
</div>
)}
</div>
);
}
// ─── Result groups + rows ────────────────────────────────────────────
function ResultGroup({
title,
icon,
isLoading,
isError,
count,
seeMoreHref,
children,
}: {
title: string;
icon: React.ReactNode;
isLoading: boolean;
isError: boolean;
count: number | undefined;
seeMoreHref?: string;
children: React.ReactNode;
}) {
const hasItems = (count ?? 0) > 0;
return (
<div className="border-b border-foreground/5 last:border-b-0">
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{icon}
<span>{title}</span>
{count !== undefined && (
<span className="text-foreground/50 normal-case font-normal">({count})</span>
)}
{isLoading && <Loader2 className="size-3 animate-spin ms-auto" aria-hidden="true" />}
</div>
{isError && (
<div className="px-3 pb-2 text-xs text-destructive">שגיאה בטעינת תוצאות</div>
)}
{!isLoading && !isError && !hasItems && (
<div className="px-3 pb-2 text-xs text-muted-foreground/70">אין תוצאות בקטגוריה זו</div>
)}
<div className="flex flex-col">{children}</div>
{seeMoreHref && hasItems && (
<Link
href={seeMoreHref}
className="block px-3 py-1.5 text-xs text-gold-deep hover:bg-accent/40 transition-colors"
>
הצג עוד
</Link>
)}
</div>
);
}
function CaseRow({ hit, onSelect }: { hit: CaseHit; onSelect: () => void }) {
return (
<Link
href={`/cases/${encodeURIComponent(hit.case_number)}`}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm">ערר {hit.case_number}</span>
{hit.status && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{hit.status}
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{hit.title}
{hit.property_address && <span> · {hit.property_address}</span>}
</div>
</Link>
);
}
function PrecedentRow({ hit, onSelect }: { hit: PrecedentHit; onSelect: () => void }) {
const href = `/precedents/${hit.case_law_id}`;
const pct = Math.round(hit.score * 100);
if (hit.type === "halacha") {
return (
<Link
href={href}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm line-clamp-1">הלכה {hit.rule_statement}</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{hit.case_name} · {hit.case_number}
</div>
</Link>
);
}
return (
<Link
href={href}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm">פסקה</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{hit.content.slice(0, 160)}
</div>
<div className="text-[11px] text-muted-foreground/70">
{hit.case_name} · {hit.case_number}
</div>
</Link>
);
}
function DocumentRow({ hit, onSelect }: { hit: DocumentHit; onSelect: () => void }) {
const pct = Math.round(hit.score * 100);
return (
<Link
href={`/cases/${encodeURIComponent(hit.case_number)}`}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm truncate">{hit.document}</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{hit.content.slice(0, 160)}
</div>
<div className="text-[11px] text-muted-foreground/70">
ערר {hit.case_number}
{hit.page != null && <span> · עמ׳ {hit.page}</span>}
</div>
</Link>
);
}