● Zion Boggan
repos/Sightline/frontend/src/app/profiles/page.tsx
zionboggan.com ↗
161 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useState } from "react";
3
import { api, ProfileDossier, ProfileScope, ProfileSubject } from "@/lib/api";
4
 
5
export default function ProfilesPage() {
6
  const [scope, setScope] = useState<ProfileScope | null>(null);
7
  const [q, setQ] = useState("");
8
  const [results, setResults] = useState<ProfileSubject[]>([]);
9
  const [dossier, setDossier] = useState<ProfileDossier | null>(null);
10
  const [err, setErr] = useState<string | null>(null);
11
 
12
  const loadScope = () => api.profileScope().then(setScope).catch(() => {});
13
  useEffect(() => { loadScope(); }, []);
14
 
15
  async function toggleScope(mode: "consented" | "all") {
16
    try { await api.setProfileScope(mode); loadScope(); setResults([]); setDossier(null); }
17
    catch { setErr("Scope change needs admin (open mode = admin)."); }
18
  }
19
  async function search() {
20
    setErr(null); setDossier(null);
21
    try { setResults((await api.profileSearch(q)).results); }
22
    catch (e: any) { setErr("Search failed."); }
23
  }
24
  async function open(id: string) {
25
    setErr(null);
26
    try { setDossier(await api.buildProfile(id)); }
27
    catch (e: any) { setErr(e.status === 403 ? "Locked: subject is not a consented subject." : "Build failed."); }
28
  }
29
  async function addSubject() {
30
    const name = prompt("Subject full name (consented)"); if (!name) return;
31
    const plates = (prompt("Plate(s), comma-separated") || "").split(",").map((s) => s.trim()).filter(Boolean);
32
    try { await api.createSubject(name, plates, true); search(); }
33
    catch { setErr("Could not create subject."); }
34
  }
35
 
36
  return (
37
    <div>
38
      <div className="row" style={{ justifyContent: "space-between" }}>
39
        <div>
40
          <h1>Profile Engine</h1>
41
          <p className="sub">Entity-resolution dossier builder - identity, vehicles, cross-state movement, detections.</p>
42
        </div>
43
      </div>
44
 
45
      {}
46
      <div className="card" style={{ borderColor: scope?.mode === "consented" ? "var(--ok)" : "var(--amber)" }}>
47
        <div className="row" style={{ justifyContent: "space-between" }}>
48
          <div>
49
            <div className="row" style={{ gap: 8 }}>
50
              <strong style={{ fontFamily: "var(--mono)" }}>SCOPE</strong>
51
              <span className={"badge " + (scope?.mode === "consented" ? "ok" : "amber")}>
52
                {scope?.mode === "consented" ? "● CONSENTED SUBJECTS ONLY" : "● ALL INGESTED DATA"}
53
              </span>
54
            </div>
55
            <div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{scope?.description}</div>
56
          </div>
57
          <div className="row">
58
            <span className={"chip " + (scope?.mode === "consented" ? "on" : "")} onClick={() => toggleScope("consented")}>Me + Brother only</span>
59
            <span className={"chip " + (scope?.mode === "all" ? "on" : "")} onClick={() => toggleScope("all")}>All data</span>
60
          </div>
61
        </div>
62
        <div className="muted" style={{ fontSize: 11, marginTop: 8 }}>
63
          {scope?.consented_subjects} consented subject(s) registered. In consented mode the engine
64
          will refuse to build a profile for anyone not whitelisted.
65
        </div>
66
      </div>
67
 
68
      <div className="row" style={{ margin: "14px 0" }}>
69
        <input placeholder="search subject name…" value={q} onChange={(e) => setQ(e.target.value)}
70
               onKeyDown={(e) => e.key === "Enter" && search()} style={{ minWidth: 260 }} />
71
        <button className="primary" onClick={search}>Search</button>
72
        <button onClick={addSubject}>+ Add consented subject</button>
73
      </div>
74
      {err && <div className="badge danger">{err}</div>}
75
 
76
      {results.length > 0 && !dossier && (
77
        <div className="card">
78
          <table>
79
            <thead><tr><th>Name</th><th>Plates</th><th>Status</th><th></th></tr></thead>
80
            <tbody>
81
              {results.map((r) => (
82
                <tr key={r.id}>
83
                  <td>{r.full_name}</td>
84
                  <td className="mono">{r.plates.join(", ") || "-"}</td>
85
                  <td>{r.consented ? <span className="badge ok">consented</span> : <span className="badge amber">not consented</span>}</td>
86
                  <td><button onClick={() => open(r.id)}>Build profile →</button></td>
87
                </tr>
88
              ))}
89
            </tbody>
90
          </table>
91
        </div>
92
      )}
93
 
94
      {dossier && (
95
        <div>
96
          <div className="row" style={{ justifyContent: "space-between", marginTop: 8 }}>
97
            <h2 style={{ margin: 0 }}>{dossier.subject.full_name}</h2>
98
            <button onClick={() => setDossier(null)}>← Back</button>
99
          </div>
100
          <div className="row" style={{ margin: "8px 0" }}>
101
            {dossier.subject.consented && <span className="badge ok">consented subject</span>}
102
            <span className="badge mono">{dossier.subject.plates.join(", ") || "no plate"}</span>
103
          </div>
104
 
105
          <div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill,minmax(170px,1fr))" }}>
106
            <div className="kpi-card"><div className="lbl">Total reads</div><div className="val accent">{dossier.summary.total_reads}</div></div>
107
            <div className="kpi-card"><div className="lbl">States visited</div><div className="val">{dossier.summary.states_visited.length}</div></div>
108
            <div className="kpi-card"><div className="lbl">Cities</div><div className="val">{dossier.summary.cities_visited.length}</div></div>
109
            <div className="kpi-card"><div className="lbl">Cameras</div><div className="val">{dossier.summary.distinct_cameras}</div></div>
110
          </div>
111
 
112
          {dossier.summary.states_visited.length > 0 && (
113
            <div className="row" style={{ margin: "12px 0" }}>
114
              <span className="muted">States:</span>
115
              {dossier.summary.states_visited.map((s) => <span key={s} className="badge accent">{s}</span>)}
116
            </div>
117
          )}
118
 
119
          <h2>Movement timeline</h2>
120
          <div className="card scroll" style={{ maxHeight: 420 }}>
121
            <table>
122
              <thead><tr><th>Time</th><th>Camera</th><th>State</th><th>City</th><th>Plate</th></tr></thead>
123
              <tbody>
124
                {dossier.timeline.map((t, i) => (
125
                  <tr key={i}>
126
                    <td className="mono">{new Date(t.read_ts).toLocaleString()}</td>
127
                    <td>{t.camera || "-"}</td>
128
                    <td><span className="badge">{t.state || "-"}</span></td>
129
                    <td className="muted">{t.area || "-"}</td>
130
                    <td className="mono">{t.plate_norm}</td>
131
                  </tr>
132
                ))}
133
                {!dossier.timeline.length && <tr><td colSpan={5} className="muted">
134
                  No sightings yet. Ingest reads for this subject's plate (run the ALPR engine on
135
                  your own car/footage, or import from your Flock unit) and they'll appear here.
136
                </td></tr>}
137
              </tbody>
138
            </table>
139
          </div>
140
 
141
          {dossier.detections.length > 0 && (
142
            <>
143
              <h2>Appearance detections</h2>
144
              <div className="card">
145
                <table>
146
                  <thead><tr><th>Time</th><th>Clothing</th><th>Articles</th></tr></thead>
147
                  <tbody>
148
                    {dossier.detections.map((d, i) => (
149
                      <tr key={i}><td className="mono">{new Date(d.read_ts).toLocaleString()}</td>
150
                        <td>{d.clothing_top}</td><td className="muted">{(d.articles || []).join(", ")}</td></tr>
151
                    ))}
152
                  </tbody>
153
                </table>
154
              </div>
155
            </>
156
          )}
157
        </div>
158
      )}
159
    </div>
160
  );
161
}