● Zion Boggan
repos/Sightline/frontend/src/app/wall/page.tsx
zionboggan.com ↗
125 lines · tsx
History for this file →
1
"use client";
2
import { useCallback, useEffect, useRef, useState } from "react";
3
import { api, LiveCamera } from "@/lib/api";
4
 
5
const AREAS = ["", "Manhattan", "Brooklyn", "Queens", "Bronx"];
6
const REFRESH_MS = 18000;
7
 
8
 
9
function CamTile({ cam, onOpen }: { cam: LiveCamera; onOpen: (c: LiveCamera) => void }) {
10
  const imgRef = useRef<HTMLImageElement>(null);
11
  const visible = useRef(true);
12
  const url = api.snapshotUrl(cam.id);
13
 
14
  useEffect(() => {
15
    const el = imgRef.current;
16
    if (!el) return;
17
    const io = new IntersectionObserver(([e]) => { visible.current = e.isIntersecting; },
18
      { rootMargin: "200px" });
19
    io.observe(el);
20
    function refresh() {
21
      if (!visible.current) return;
22
      const next = new Image();
23
      next.onload = () => { if (imgRef.current) { imgRef.current.src = next.src; imgRef.current.style.opacity = "1"; } };
24
      next.onerror = () => { if (imgRef.current) imgRef.current.style.opacity = "0.18"; };
25
      next.src = `${url}?t=${Date.now()}`;
26
    }
27
    refresh();
28
    const start = setTimeout(() => { refresh(); }, Math.random() * REFRESH_MS);
29
    const iv = setInterval(refresh, REFRESH_MS);
30
    return () => { io.disconnect(); clearTimeout(start); clearInterval(iv); };
31
  }, [url]);
32
 
33
  return (
34
    <div className="tile" onClick={() => onOpen(cam)}>
35
      <img ref={imgRef} alt={cam.name} style={{ opacity: 0 }} />
36
      <div className={"live" + (cam.online ? "" : " off")}><span className="led" />{cam.online ? "LIVE" : "OFFLINE"}</div>
37
      <div className="vtag">SNAPSHOT</div>
38
      <div className="meta">
39
        <div className="nm">{cam.name}</div>
40
        <div className="ar">{cam.area || cam.state} · {cam.operator}</div>
41
      </div>
42
    </div>
43
  );
44
}
45
 
46
export default function LiveWall() {
47
  const [cams, setCams] = useState<LiveCamera[]>([]);
48
  const [total, setTotal] = useState(0);
49
  const [state, setState] = useState("");
50
  const [area, setArea] = useState("");
51
  const [q, setQ] = useState("");
52
  const [page, setPage] = useState(0);
53
  const [dense, setDense] = useState(false);
54
  const [active, setActive] = useState<LiveCamera | null>(null);
55
  const [err, setErr] = useState<string | null>(null);
56
  const limit = 48;
57
 
58
  const load = useCallback(() => {
59
    const query = [area, q].filter(Boolean).join(" ") || undefined;
60
    api.liveCameras({ state: state || undefined, q: query, limit, offset: page * limit })
61
      .then((r) => { setCams(r.cameras); setTotal(r.total); })
62
      .catch((e) => setErr(e.status === 401 ? "Sign in to view live feeds." : "Load failed."));
63
  }, [state, area, q, page]);
64
  useEffect(load, [load]);
65
 
66
  const STATES = ["", "NY", "CA", "OR", "FL", "AZ", "VT", "NH", "ME"];
67
 
68
  return (
69
    <div>
70
      <div className="row" style={{ justifyContent: "space-between" }}>
71
        <div>
72
          <div className="eyebrow">Operations / Live Wall</div>
73
          <h1 style={{ marginTop: 8 }}>Live Camera Wall</h1>
74
          <p className="sub">Real public DOT feeds - server-cached &amp; staggered, ~18s refresh, on-screen tiles only.</p>
75
        </div>
76
        <span className="feedmark"><span className="led" /> {total.toLocaleString()} live feeds</span>
77
      </div>
78
 
79
      <div className="wall-bar">
80
        <select value={state} onChange={(e) => { setState(e.target.value); setArea(""); setPage(0); }}>
81
          {STATES.map((s) => <option key={s} value={s}>{s || "All states"}</option>)}
82
        </select>
83
        {state === "NY" && AREAS.map((a) => (
84
          <span key={a} className={"chip " + (area === a ? "on" : "")} onClick={() => { setArea(a); setPage(0); }}>{a || "All NYC"}</span>
85
        ))}
86
        <input placeholder="location…" value={q} onChange={(e) => { setQ(e.target.value); setPage(0); }} style={{ width: 170 }} />
87
        <div style={{ flex: 1 }} />
88
        <span className={"chip " + (dense ? "on" : "")} onClick={() => setDense((d) => !d)}>{dense ? "Dense" : "Wide"}</span>
89
        <span className="muted" style={{ fontSize: 11 }}>{cams.length} / {total.toLocaleString()}</span>
90
      </div>
91
      {err && <div className="badge danger">{err}</div>}
92
 
93
      <div className={"wall" + (dense ? " dense" : "")}>
94
        {cams.map((c) => <CamTile key={c.id} cam={c} onOpen={setActive} />)}
95
        {!cams.length && !err && <div className="muted" style={{ padding: 20 }}>Loading live feeds…</div>}
96
      </div>
97
 
98
      <div className="row" style={{ marginTop: 16, justifyContent: "center" }}>
99
        <button disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>← Prev</button>
100
        <span className="muted">page {page + 1} / {Math.max(1, Math.ceil(total / limit))}</span>
101
        <button disabled={(page + 1) * limit >= total} onClick={() => setPage((p) => p + 1)}>Next →</button>
102
      </div>
103
 
104
      {active && (
105
        <div className="viewer-backdrop" onClick={() => setActive(null)}>
106
          <div className="viewer" onClick={(e) => e.stopPropagation()}>
107
            <div className="vimg"><img src={`${api.snapshotUrl(active.id)}?t=${Date.now()}`} alt={active.name} /></div>
108
            <div className="vbody">
109
              <div className="row" style={{ justifyContent: "space-between" }}>
110
                <div>
111
                  <div style={{ fontFamily: "var(--display)", fontWeight: 700, textTransform: "uppercase" }}>{active.name}</div>
112
                  <div className="muted" style={{ fontSize: 11, marginTop: 3 }}>{active.area || active.state} · {active.operator} · {active.lat?.toFixed(4)}, {active.lon?.toFixed(4)}</div>
113
                </div>
114
                <div className="row">
115
                  <span className="feedmark"><span className="led" /> LIVE · SNAPSHOT</span>
116
                  <button onClick={() => setActive(null)}>Close</button>
117
                </div>
118
              </div>
119
            </div>
120
          </div>
121
        </div>
122
      )}
123
    </div>
124
  );
125
}