| 1 | "use client"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { useRouter } from "next/navigation"; |
| 4 | import { api, OmniResults } from "@/lib/api"; |
| 5 | |
| 6 | export default function OmniSearch() { |
| 7 | const [q, setQ] = useState(""); |
| 8 | const [res, setRes] = useState<OmniResults | null>(null); |
| 9 | const [open, setOpen] = useState(false); |
| 10 | const router = useRouter(); |
| 11 | const box = useRef<HTMLDivElement>(null); |
| 12 | const timer = useRef<any>(null); |
| 13 | |
| 14 | useEffect(() => { |
| 15 | function onClick(e: MouseEvent) { |
| 16 | if (box.current && !box.current.contains(e.target as Node)) setOpen(false); |
| 17 | } |
| 18 | document.addEventListener("mousedown", onClick); |
| 19 | return () => document.removeEventListener("mousedown", onClick); |
| 20 | }, []); |
| 21 | |
| 22 | function onChange(v: string) { |
| 23 | setQ(v); |
| 24 | clearTimeout(timer.current); |
| 25 | if (v.trim().length < 2) { setRes(null); return; } |
| 26 | timer.current = setTimeout(() => { |
| 27 | api.omniSearch(v.trim()).then((r) => { setRes(r); setOpen(true); }).catch(() => setRes(null)); |
| 28 | }, 220); |
| 29 | } |
| 30 | |
| 31 | const hasResults = res && (res.plates.length || res.persons.length || res.vehicles.length || res.detections.length || res.cameras.length); |
| 32 | |
| 33 | return ( |
| 34 | <div className="omni" ref={box}> |
| 35 | <span className="mag">⌕</span> |
| 36 | <input value={q} placeholder="Search anything - plate, name, make/model, clothing, camera operator…" |
| 37 | onChange={(e) => onChange(e.target.value)} onFocus={() => res && setOpen(true)} /> |
| 38 | {open && hasResults && ( |
| 39 | <div className="omni-results"> |
| 40 | {res!.plates.length > 0 && ( |
| 41 | <div className="omni-sec"><div className="lbl">Plates</div> |
| 42 | {res!.plates.map((p) => ( |
| 43 | <div key={p.plate_norm} className="omni-row" onClick={() => { router.push(`/vehicles/${p.plate_norm}`); setOpen(false); }}> |
| 44 | <span className="badge accent mono">{p.plate_norm}</span> |
| 45 | <span className="muted">{p.state} · {p.reads} reads</span> |
| 46 | </div> |
| 47 | ))} |
| 48 | </div> |
| 49 | )} |
| 50 | {res!.persons.length > 0 && ( |
| 51 | <div className="omni-sec"><div className="lbl">Persons</div> |
| 52 | {res!.persons.map((p) => ( |
| 53 | <div key={p.id} className="omni-row" onClick={() => { router.push(`/persons/${p.id}`); setOpen(false); }}> |
| 54 | <span>{p.full_name}</span> |
| 55 | {p.wanted && <span className="badge danger">WANTED</span>} |
| 56 | <span className="muted">{p.associated_plate || ""}</span> |
| 57 | </div> |
| 58 | ))} |
| 59 | </div> |
| 60 | )} |
| 61 | {res!.vehicles.length > 0 && ( |
| 62 | <div className="omni-sec"><div className="lbl">Vehicles</div> |
| 63 | {res!.vehicles.map((v, i) => ( |
| 64 | <div key={i} className="omni-row" onClick={() => { if (v.plate_norm) router.push(`/vehicles/${v.plate_norm}`); setOpen(false); }}> |
| 65 | <span className="badge">{[v.color, v.make, v.model].filter(Boolean).join(" ")}</span> |
| 66 | <span className="muted mono">{v.plate_norm}</span> |
| 67 | </div> |
| 68 | ))} |
| 69 | </div> |
| 70 | )} |
| 71 | {res!.detections.length > 0 && ( |
| 72 | <div className="omni-sec"><div className="lbl">Detections (clothing / articles)</div> |
| 73 | {res!.detections.map((d) => ( |
| 74 | <div key={d.id} className="omni-row" onClick={() => { router.push(`/search?det=${d.id}`); setOpen(false); }}> |
| 75 | <span className="badge purple">{d.clothing_top}</span> |
| 76 | <span className="muted">{(d.articles || []).join(", ")} {d.plate_norm ? `· ${d.plate_norm}` : ""}</span> |
| 77 | </div> |
| 78 | ))} |
| 79 | </div> |
| 80 | )} |
| 81 | {res!.cameras.length > 0 && ( |
| 82 | <div className="omni-sec"><div className="lbl">Cameras</div> |
| 83 | {res!.cameras.map((c) => ( |
| 84 | <div key={c.id} className="omni-row" onClick={() => { router.push(`/cameras/${c.id}`); setOpen(false); }}> |
| 85 | <span>{c.name}</span><span className="muted">{c.operator}</span> |
| 86 | </div> |
| 87 | ))} |
| 88 | </div> |
| 89 | )} |
| 90 | </div> |
| 91 | )} |
| 92 | </div> |
| 93 | ); |
| 94 | } |