● Zion Boggan
repos/Sightline/frontend/src/app/search/page.tsx
zionboggan.com ↗
108 lines · tsx
History for this file →
1
"use client";
2
import { Suspense, useEffect, useState } from "react";
3
import { useSearchParams } from "next/navigation";
4
import { api, MovementSummary, PlateProfile } from "@/lib/api";
5
 
6
function SearchInner() {
7
  const params = useSearchParams();
8
  const [q, setQ] = useState(params.get("q") || "");
9
  const [partial, setPartial] = useState(false);
10
  const [results, setResults] = useState<{ plate_norm: string; state: string }[]>([]);
11
  const [profile, setProfile] = useState<PlateProfile | null>(null);
12
  const [movement, setMovement] = useState<MovementSummary | null>(null);
13
  const [err, setErr] = useState<string | null>(null);
14
 
15
  async function run(plate?: string) {
16
    setErr(null); setProfile(null); setMovement(null);
17
    const query = plate || q;
18
    if (!query) return;
19
    try {
20
      if (plate) {
21
        const [p, m] = await Promise.all([api.plateProfile(plate), api.movement(plate)]);
22
        setProfile(p); setMovement(m); setResults([]);
23
      } else {
24
        setResults(await api.plateSearch(query, partial));
25
      }
26
    } catch (e: any) {
27
      setErr(e.status === 401 ? "Sign in to search." : "No results.");
28
    }
29
  }
30
 
31
  useEffect(() => { if (params.get("q")) run(params.get("q")!);  }, []);
32
 
33
  return (
34
    <div>
35
      <h1>Plate Search</h1>
36
      <div className="row" style={{ margin: "12px 0" }}>
37
        <input value={q} onChange={(e) => setQ(e.target.value.toUpperCase())} placeholder="ABC1234" />
38
        <label className="row"><input type="checkbox" checked={partial} onChange={(e) => setPartial(e.target.checked)} style={{ width: 16 }} /> partial</label>
39
        <button className="primary" onClick={() => run()}>Search</button>
40
      </div>
41
      {err && <div className="badge danger">{err}</div>}
42
 
43
      {results.length > 0 && (
44
        <div className="card">
45
          <table>
46
            <thead><tr><th>Plate</th><th>State</th></tr></thead>
47
            <tbody>{results.map((r) => (
48
              <tr key={r.plate_norm}><td><a onClick={() => run(r.plate_norm)} style={{ cursor: "pointer" }}>{r.plate_norm}</a></td><td>{r.state}</td></tr>
49
            ))}</tbody>
50
          </table>
51
        </div>
52
      )}
53
 
54
      {profile && (
55
        <>
56
          <h2>{profile.plate_norm} <a href={`/vehicles/${profile.plate_norm}`} style={{ fontSize: 13 }}>full vehicle profile →</a></h2>
57
          <div className="grid">
58
            <div className="card"><div className="muted">Total reads</div><div className="kpi">{profile.total_reads}</div></div>
59
            <div className="card"><div className="muted">Distinct cameras</div><div className="kpi">{profile.distinct_cameras}</div></div>
60
            <div className="card"><div className="muted">First seen</div><div>{fmt(profile.first_seen)}</div></div>
61
            <div className="card"><div className="muted">Last seen</div><div>{fmt(profile.last_seen)}</div></div>
62
          </div>
63
        </>
64
      )}
65
 
66
      {movement && movement.anomalies.length > 0 && (
67
        <>
68
          <h2>⚠ Movement anomalies</h2>
69
          <div className="card" style={{ borderColor: "var(--danger)" }}>
70
            {movement.anomalies.map((a, i) => (
71
              <div key={i} className="row" style={{ justifyContent: "space-between" }}>
72
                <span>{a.reason.replace(/_/g, " ")}</span>
73
                <span className="badge danger">{Math.round(a.speed_kmh)} km/h</span>
74
              </div>
75
            ))}
76
            <p className="muted" style={{ marginBottom: 0 }}>Implausible transit speed suggests a misread or cloned plate.</p>
77
          </div>
78
        </>
79
      )}
80
 
81
      {movement && (
82
        <>
83
          <h2>Route legs</h2>
84
          <div className="card">
85
            <table>
86
              <thead><tr><th>From</th><th>To</th><th>Elapsed</th><th>Distance</th><th>Speed</th></tr></thead>
87
              <tbody>{movement.route.map((l, i) => (
88
                <tr key={i}>
89
                  <td className="muted">{l.from?.slice(0, 8)}</td>
90
                  <td className="muted">{l.to?.slice(0, 8)}</td>
91
                  <td>{Math.round(l.elapsed_s)}s</td>
92
                  <td>{l.distance_km ? l.distance_km.toFixed(1) + " km" : "-"}</td>
93
                  <td>{l.speed_kmh ? <span className={"badge " + (l.implausible ? "danger" : "ok")}>{Math.round(l.speed_kmh)} km/h</span> : "-"}</td>
94
                </tr>
95
              ))}</tbody>
96
            </table>
97
          </div>
98
        </>
99
      )}
100
    </div>
101
  );
102
}
103
 
104
function fmt(s: string | null) { return s ? new Date(s).toLocaleString() : "-"; }
105
 
106
export default function Search() {
107
  return <Suspense fallback={<div className="muted">Loading…</div>}><SearchInner /></Suspense>;
108
}