● Zion Boggan
repos/Sightline/frontend/src/app/cameras/page.tsx
zionboggan.com ↗
95 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useState } from "react";
3
import Link from "next/link";
4
import { api, CameraGridItem, CameraStats } from "@/lib/api";
5
 
6
const STATES = ["","AL","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS","KY",
7
  "LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY","NC","ND","OH","OK",
8
  "OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV","WI","WY"];
9
 
10
export default function CamerasPage() {
11
  const [items, setItems] = useState<CameraGridItem[]>([]);
12
  const [total, setTotal] = useState(0);
13
  const [stats, setStats] = useState<CameraStats | null>(null);
14
  const [state, setState] = useState("");
15
  const [q, setQ] = useState("");
16
  const [page, setPage] = useState(0);
17
  const [err, setErr] = useState<string | null>(null);
18
  const limit = 60;
19
 
20
  useEffect(() => { api.cameraStats().then(setStats).catch(() => {}); }, []);
21
  useEffect(() => {
22
    api.cameraGrid({ state: state || undefined, q: q || undefined, limit, offset: page * limit })
23
      .then((r) => { setItems(r.cameras); setTotal(r.total); })
24
      .catch((e) => setErr(e.status === 401 ? "Sign in to view cameras." : "Load failed."));
25
  }, [state, q, page]);
26
 
27
  function opColor(op: string | null) {
28
    const o = (op || "").toLowerCase();
29
    if (o.includes("flock")) return "accent";
30
    if (o.includes("motorola")) return "amber";
31
    if (o.includes("genetec")) return "purple";
32
    return "";
33
  }
34
 
35
  return (
36
    <div>
37
      <h1>Camera Grid</h1>
38
      <p className="sub">{stats ? `${stats.total.toLocaleString()} real cameras across ${stats.by_state.length} states · ${stats.satellite_cameras} satellite cameras` : "Loading…"}</p>
39
 
40
      {stats && (
41
        <div className="row" style={{ margin: "10px 0", gap: 8 }}>
42
          {stats.by_operator.slice(0, 6).map((o) => (
43
            <span key={o.operator} className={"badge " + opColor(o.operator)}>{o.operator}: {o.count.toLocaleString()}</span>
44
          ))}
45
        </div>
46
      )}
47
 
48
      <div className="row" style={{ margin: "12px 0" }}>
49
        <select value={state} onChange={(e) => { setState(e.target.value); setPage(0); }}>
50
          {STATES.map((s) => <option key={s} value={s}>{s || "All states"}</option>)}
51
        </select>
52
        <input placeholder="filter by name / operator" value={q}
53
               onChange={(e) => { setQ(e.target.value); setPage(0); }} />
54
        <span className="muted">{total.toLocaleString()} match</span>
55
      </div>
56
      {err && <div className="badge danger">{err}</div>}
57
 
58
      <div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(240px,1fr))" }}>
59
        {items.map((c) => (
60
          <Link key={c.id} href={c.has_live_feed ? "/wall" : `/cameras/${c.id}`} className="card" style={{ display: "block", padding: 0, overflow: "hidden" }}>
61
            {c.has_live_feed && c.snapshot_url && (
62
              <div style={{ position: "relative", aspectRatio: "16/9", background: "#000" }}>
63
                {}
64
                <img src={api.snapshotUrl(c.id)} alt={c.name} loading="lazy"
65
                     style={{ width: "100%", height: "100%", objectFit: "cover" }}
66
                     onError={(e) => { (e.target as HTMLImageElement).style.opacity = "0.2"; }} />
67
                <div className="live" style={{ position: "absolute", top: 6, left: 6 }}><span className="led" />LIVE</div>
68
              </div>
69
            )}
70
            <div style={{ padding: 14 }}>
71
              <div className="row" style={{ justifyContent: "space-between" }}>
72
                <span className={"badge " + opColor(c.operator)}>{c.operator || "Unknown"}</span>
73
                <span className="badge">{c.state}</span>
74
              </div>
75
              <div style={{ marginTop: 8, fontWeight: 600, fontSize: 12, textTransform: "uppercase", letterSpacing: ".02em" }}>{c.name}</div>
76
              <div className="muted mono" style={{ fontSize: 10.5, marginTop: 4 }}>
77
                {c.lat?.toFixed(4)}, {c.lon?.toFixed(4)} {c.direction_facing ? `· ${c.direction_facing}` : ""}
78
              </div>
79
              <div className="muted" style={{ fontSize: 10.5, marginTop: 2 }}>
80
                {c.has_live_feed ? <span className="feedmark"><span className="led" /> LIVE FEED</span> : `${c.zone || "ALPR"} · ${c.reads} reads`}
81
              </div>
82
            </div>
83
          </Link>
84
        ))}
85
        {!items.length && !err && <div className="muted">No cameras.</div>}
86
      </div>
87
 
88
      <div className="row" style={{ marginTop: 16, justifyContent: "center" }}>
89
        <button disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>← Prev</button>
90
        <span className="muted">page {page + 1} of {Math.max(1, Math.ceil(total / limit))}</span>
91
        <button disabled={(page + 1) * limit >= total} onClick={() => setPage((p) => p + 1)}>Next →</button>
92
      </div>
93
    </div>
94
  );
95
}