| 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 | } |