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