Files
mylegal-stage-law/src/components/global-search.tsx
T
2026-04-18 23:08:58 +00:00

212 lines
6.6 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { Search, Briefcase, Users, UserPlus, FileText, Loader2 } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { cn } from "@/lib/utils";
type SearchResult = {
id: string;
label: string;
sublabel?: string;
kind: "case" | "client" | "contact" | "document";
to: string;
};
const KIND_ICON = {
case: Briefcase,
client: Users,
contact: UserPlus,
document: FileText,
} as const;
const KIND_LABEL = {
case: "Case",
client: "Client",
contact: "Contact",
document: "Document",
} as const;
export function GlobalSearch() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
// Debounced search
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
const handle = setTimeout(async () => {
const like = `%${q}%`;
const [casesRes, clientsRes, contactsRes, docsRes] = await Promise.all([
supabase
.from("cases")
.select("id,title,case_number")
.or(`title.ilike.${like},case_number.ilike.${like}`)
.is("archived_at", null)
.limit(6),
supabase
.from("clients")
.select("id,name")
.ilike("name", like)
.is("archived_at", null)
.limit(6),
supabase
.from("contacts")
.select("id,name,company,email")
.or(`name.ilike.${like},company.ilike.${like},email.ilike.${like}`)
.is("archived_at", null)
.limit(6),
supabase
.from("documents")
.select("id,name,case_id")
.ilike("name", like)
.limit(6),
]);
const out: SearchResult[] = [];
casesRes.data?.forEach((c) =>
out.push({
id: c.id,
label: c.title,
sublabel: c.case_number,
kind: "case",
to: `/cases/${c.id}`,
}),
);
clientsRes.data?.forEach((c) =>
out.push({ id: c.id, label: c.name, kind: "client", to: `/clients/${c.id}` }),
);
contactsRes.data?.forEach((c) =>
out.push({
id: c.id,
label: c.name,
sublabel: c.company || c.email || undefined,
kind: "contact",
to: `/contacts/${c.id}`,
}),
);
docsRes.data?.forEach((d) =>
out.push({
id: d.id,
label: d.name,
sublabel: "Document",
kind: "document",
to: d.case_id ? `/cases/${d.case_id}` : `/files`,
}),
);
setResults(out);
setActiveIdx(0);
setLoading(false);
}, 250);
return () => clearTimeout(handle);
}, [query]);
const select = (r: SearchResult) => {
setOpen(false);
setQuery("");
setResults([]);
navigate({ to: r.to });
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && results[activeIdx]) {
e.preventDefault();
select(results[activeIdx]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={containerRef} className="relative px-3 pt-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-sidebar-foreground/50" />
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder="Search…"
className="w-full h-8 pl-8 pr-2 text-sm rounded-md bg-sidebar-accent/40 text-sidebar-foreground placeholder:text-sidebar-foreground/50 border border-sidebar-border focus:outline-none focus:ring-1 focus:ring-sidebar-primary"
/>
{loading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-sidebar-foreground/50" />
)}
</div>
{open && query.trim().length >= 2 && (
<div className="absolute left-3 right-3 top-full mt-1 z-50 max-h-96 overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-lg">
{results.length === 0 && !loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground text-center">
No results
</div>
) : (
<ul className="py-1">
{results.map((r, idx) => {
const Icon = KIND_ICON[r.kind];
return (
<li key={`${r.kind}-${r.id}`}>
<button
type="button"
onMouseEnter={() => setActiveIdx(idx)}
onClick={() => select(r)}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm",
idx === activeIdx ? "bg-accent text-accent-foreground" : "",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate">{r.label}</div>
{r.sublabel && (
<div className="truncate text-[11px] text-muted-foreground">
{r.sublabel}
</div>
)}
</div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{KIND_LABEL[r.kind]}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}