feat: initial commit
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🎥 Video Surveillance Portal</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Orbitron:wght@400;700&display=swap');
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--blood:#8b0000;--blood-bright:#cc0000;--crimson:#660000;
|
||||
--ash:#2a2525;--bone:#c0b8b0;--dark:#050505;--panel:rgba(8,5,5,.94);
|
||||
--border:rgba(139,0,0,.2);--text:#b0a8a0;--dim:#555;
|
||||
}
|
||||
body{background:var(--dark);color:var(--text);font-family:'Orbitron',sans-serif;min-height:100vh}
|
||||
#app{max-width:1400px;margin:0 auto;padding:15px}
|
||||
h1{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);text-shadow:0 0 8px var(--crimson);text-align:center;margin-bottom:5px;font-size:1.8rem;letter-spacing:3px}
|
||||
.subtitle{text-align:center;color:#666;font-size:.7rem;margin-bottom:18px;font-family:'Cinzel',serif}
|
||||
|
||||
/* NAV */
|
||||
nav{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-bottom:18px}
|
||||
nav button{font-family:'Cinzel',serif;font-size:.7rem;font-weight:600;padding:8px 18px;background:transparent;color:var(--bone);border:1px solid var(--border);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .25s}
|
||||
nav button:hover,nav button.active{background:rgba(139,0,0,.3);border-color:var(--blood-bright);color:#fff;box-shadow:0 0 10px var(--crimson)}
|
||||
.tab{display:none}.tab.active{display:block}
|
||||
|
||||
/* PANELS */
|
||||
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px;margin-bottom:15px}
|
||||
.panel h2{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);margin-bottom:12px;font-size:1rem;letter-spacing:2px}
|
||||
|
||||
/* CAMERA GRID */
|
||||
.cam-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px}
|
||||
.cam-card{background:rgba(0,0,0,.4);border:1px solid var(--border);border-radius:8px;overflow:hidden;position:relative}
|
||||
.cam-card video,.cam-card img{width:100%;height:240px;object-fit:cover;background:#000;display:block}
|
||||
.cam-card .cam-label{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.75);color:var(--bone);padding:4px 10px;border-radius:4px;font-size:.65rem;font-family:'Cinzel',serif}
|
||||
.cam-card .cam-status{position:absolute;top:8px;right:8px;width:10px;height:10px;border-radius:50%}
|
||||
.cam-card .cam-status.online{background:#0f0;box-shadow:0 0 8px #0f0}
|
||||
.cam-card .cam-status.offline{background:#f00;box-shadow:0 0 8px #f00}
|
||||
.cam-card .cam-ptz{position:absolute;bottom:8px;right:8px;display:flex;gap:4px}
|
||||
.cam-card .cam-ptz button{width:28px;height:28px;background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);border-radius:4px;color:#fff;cursor:pointer;font-size:.7rem;display:flex;align-items:center;justify-content:center}
|
||||
.cam-card .cam-ptz button:hover{background:rgba(139,0,0,.5)}
|
||||
.cam-card .cam-actions{position:absolute;bottom:8px;left:8px;display:flex;gap:4px}
|
||||
.cam-card .cam-actions button{font-family:'Cinzel',serif;font-size:.55rem;padding:4px 8px;background:rgba(139,0,0,.4);color:#fff;border:none;border-radius:3px;cursor:pointer}
|
||||
|
||||
/* PHONE CAMERA */
|
||||
.phone-cam-section{text-align:center;padding:30px}
|
||||
#phoneStreamBtn{font-family:'Cinzel',serif;font-weight:700;font-size:1rem;padding:14px 36px;background:rgba(139,0,0,.5);color:#fff;border:2px solid var(--blood-bright);border-radius:8px;cursor:pointer;text-transform:uppercase;letter-spacing:2px;box-shadow:0 0 15px var(--crimson);transition:all .3s}
|
||||
#phoneStreamBtn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 25px var(--blood)}
|
||||
#phoneStreamBtn.streaming{background:rgba(0,100,0,.5);border-color:#0f0;box-shadow:0 0 15px #0f0}
|
||||
#phonePreview{max-width:400px;margin:12px auto;border-radius:8px;overflow:hidden;display:none}
|
||||
#phonePreview video{width:100%;height:auto;background:#000}
|
||||
|
||||
/* FORMS */
|
||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
|
||||
.form-row input,.form-row select{font-family:'Orbitron',sans-serif;font-size:.7rem;padding:8px 12px;background:#000;color:var(--text);border:1px solid var(--border);border-radius:5px;min-width:140px}
|
||||
.form-row input:focus{outline:none;border-color:var(--blood-bright)}
|
||||
.form-row label{font-size:.6rem;color:var(--dim);text-transform:uppercase;letter-spacing:1px;display:block;margin-bottom:3px}
|
||||
.form-row .field{display:flex;flex-direction:column}
|
||||
.btn{font-family:'Cinzel',serif;font-weight:600;font-size:.7rem;padding:8px 18px;background:rgba(139,0,0,.4);color:#fff;border:1px solid var(--blood-bright);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .2s}
|
||||
.btn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 12px var(--crimson)}
|
||||
.btn.danger{background:rgba(80,0,0,.5);border-color:#600}
|
||||
.btn.danger:hover{background:rgba(120,0,0,.7)}
|
||||
|
||||
/* TABLES */
|
||||
table{width:100%;border-collapse:collapse;font-size:.7rem}
|
||||
th{text-align:left;padding:8px 10px;background:rgba(139,0,0,.1);border-bottom:1px solid var(--border);color:var(--bone);font-family:'Cinzel',serif;text-transform:uppercase;letter-spacing:1px;font-size:.6rem}
|
||||
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.03);color:var(--text)}
|
||||
tr:hover td{background:rgba(139,0,0,.04)}
|
||||
.badge{padding:3px 8px;border-radius:3px;font-size:.55rem;font-family:'Cinzel',serif;text-transform:uppercase}
|
||||
.badge.arrived{background:rgba(0,100,0,.3);color:#0f0}.badge.departed{background:rgba(100,0,0,.3);color:#f66}.badge.seen{background:rgba(100,100,0,.3);color:#ff0}
|
||||
.badge.human{background:rgba(139,0,0,.3);color:#f66}
|
||||
.snap-thumb{width:60px;height:40px;object-fit:cover;border-radius:3px;cursor:pointer;border:1px solid var(--border)}
|
||||
|
||||
/* TOAST */
|
||||
.toast{position:fixed;bottom:20px;right:20px;z-index:999;background:var(--panel);border:1px solid var(--blood-bright);border-radius:8px;padding:12px 18px;box-shadow:0 0 20px var(--crimson);animation:toastIn .3s ease,toastOut .3s ease 3s forwards;font-family:'Cinzel',serif;font-size:.75rem}
|
||||
@keyframes toastIn{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
@keyframes toastOut{from{transform:translateY(0);opacity:1}to{transform:translateY(40px);opacity:0}}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:500;display:flex;align-items:center;justify-content:center}
|
||||
.modal-overlay img{max-width:90vw;max-height:90vh;border-radius:8px;cursor:pointer}
|
||||
|
||||
@media(max-width:600px){.cam-grid{grid-template-columns:1fr}.form-row{flex-direction:column}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>🎥 VIDEO SURVEILLANCE</h1>
|
||||
<p class="subtitle">— Dark Portal —</p>
|
||||
|
||||
<nav>
|
||||
<button class="active" data-tab="live">📹 Live View</button>
|
||||
<button data-tab="alerts">🚨 Alerts</button>
|
||||
<button data-tab="plates">🚗 Plates</button>
|
||||
<button data-tab="settings">⚙️ Settings</button>
|
||||
</nav>
|
||||
|
||||
<!-- LIVE VIEW TAB -->
|
||||
<section class="tab active" id="tab-live">
|
||||
<div class="panel" id="phonePanel">
|
||||
<h2>📱 Phone Camera Ingest</h2>
|
||||
<div class="phone-cam-section">
|
||||
<button id="phoneStreamBtn">📱 Start Phone Camera</button>
|
||||
<div id="phonePreview"><video id="phoneLocalVideo" muted autoplay playsinline></video></div>
|
||||
<p style="color:var(--dim);font-size:.6rem;margin-top:8px">Opens your phone/PC camera and streams to the portal</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>📹 Cameras</h2>
|
||||
<div class="cam-grid" id="camGrid">
|
||||
<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added yet. Go to Settings to add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ALERTS TAB -->
|
||||
<section class="tab" id="tab-alerts">
|
||||
<div class="panel">
|
||||
<h2>🚨 Alert History</h2>
|
||||
<div style="overflow-x:auto">
|
||||
<table><thead><tr><th>Time</th><th>Camera</th><th>Type</th><th>Snapshot</th><th>Channels</th></tr></thead>
|
||||
<tbody id="alertTable"><tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PLATES TAB -->
|
||||
<section class="tab" id="tab-plates">
|
||||
<div class="panel">
|
||||
<h2>🚗 License Plate Log</h2>
|
||||
<div style="overflow-x:auto">
|
||||
<table><thead><tr><th>Time</th><th>Camera</th><th>Plate</th><th>Event</th><th>Confidence</th><th>Snapshot</th></tr></thead>
|
||||
<tbody id="plateTable"><tr><td colspan="6" style="color:var(--dim);text-align:center">No plates logged yet</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SETTINGS TAB -->
|
||||
<section class="tab" id="tab-settings">
|
||||
<div class="panel">
|
||||
<h2>➕ Add Camera</h2>
|
||||
<div class="form-row">
|
||||
<div class="field"><label>Name</label><input id="camName" placeholder="Front Door"></div>
|
||||
<div class="field"><label>RTSP URL</label><input id="camRtsp" placeholder="rtsp://192.168.1.100:554/stream1" style="min-width:260px"></div>
|
||||
<div class="field"><label>ONVIF Host</label><input id="camOnvifHost" placeholder="192.168.1.100"></div>
|
||||
<div class="field"><label>ONVIF Port</label><input id="camOnvifPort" value="80" style="min-width:60px"></div>
|
||||
<div class="field"><label>ONVIF User</label><input id="camOnvifUser" value="admin"></div>
|
||||
<div class="field"><label>ONVIF Pass</label><input id="camOnvifPass" type="password"></div>
|
||||
<button class="btn" id="addCamBtn" style="align-self:flex-end">Add Camera</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>📋 Manage Cameras</h2>
|
||||
<div id="camList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>🔑 API Keys</h2>
|
||||
<p style="color:var(--dim);font-size:.65rem;margin-bottom:10px">Set these in <code>.env</code> file or environment variables</p>
|
||||
<div class="form-row">
|
||||
<div class="field"><label>PlateRecognizer Token</label><input id="prToken" placeholder="token_..."></div>
|
||||
<div class="field"><label>Twilio SID</label><input id="twSid" placeholder="AC..."></div>
|
||||
<div class="field"><label>Twilio Token</label><input id="twToken" type="password"></div>
|
||||
<div class="field"><label>Twilio From</label><input id="twFrom" placeholder="+1234567890"></div>
|
||||
<div class="field"><label>SMS To</label><input id="smsTo" placeholder="+1234567890"></div>
|
||||
<button class="btn" id="saveKeysBtn">Save Keys</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════
|
||||
// VSP FRONTEND — Single Page App
|
||||
// ═══════════════════════════════════════════
|
||||
const API = '/api';
|
||||
let cameras = [];
|
||||
let phoneStreaming = false;
|
||||
let phonePC = null;
|
||||
let phoneStream = null;
|
||||
|
||||
// === NAVIGATION ===
|
||||
document.querySelectorAll('nav button').forEach(b=>b.addEventListener('click',()=>{
|
||||
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
|
||||
if(b.dataset.tab==='alerts') loadAlerts();
|
||||
if(b.dataset.tab==='plates') loadPlates();
|
||||
if(b.dataset.tab==='live') loadCameras();
|
||||
if(b.dataset.tab==='settings') loadSettings();
|
||||
}));
|
||||
|
||||
// === TOAST ===
|
||||
function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),3300)}
|
||||
|
||||
// === API HELPERS ===
|
||||
async function api(path,opts={}){const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});if(!r.ok){const e=await r.text();throw new Error(e)}if(r.status===204)return null;return r.json()}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// LIVE VIEW — WebRTC playback
|
||||
// ═══════════════════════════════════════════
|
||||
async function loadCameras(){
|
||||
try{
|
||||
cameras = await api('/cameras/');
|
||||
renderCamGrid();
|
||||
}catch(e){console.error(e)}
|
||||
}
|
||||
async function renderCamGrid(){
|
||||
const grid = document.getElementById('camGrid');
|
||||
if(!cameras.length){
|
||||
grid.innerHTML='<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added. Go to Settings → Add Camera.</p>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = cameras.map(c=>`
|
||||
<div class="cam-card" id="card-${c.id}">
|
||||
<div class="cam-label">${esc(c.name)}</div>
|
||||
<div class="cam-status online" id="status-${c.id}"></div>
|
||||
<video id="vid-${c.id}" muted autoplay playsinline style="width:100%;height:240px;background:#000"></video>
|
||||
<div class="cam-actions">
|
||||
<button onclick="snapshotCam(${c.id})" title="Snapshot">📸</button>
|
||||
<button onclick="deleteCam(${c.id})" title="Delete">🗑️</button>
|
||||
</div>
|
||||
<div class="cam-ptz">
|
||||
<button onclick="ptz(${c.id},'up')">▲</button>
|
||||
<button onclick="ptz(${c.id},'down')">▼</button>
|
||||
<button onclick="ptz(${c.id},'left')">◀</button>
|
||||
<button onclick="ptz(${c.id},'right')">▶</button>
|
||||
<button onclick="ptzZoom(${c.id},true)" title="Zoom in">+</button>
|
||||
<button onclick="ptzZoom(${c.id},false)" title="Zoom out">−</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Connect WebRTC for each camera
|
||||
cameras.forEach(c=>startWHEP(c.id));
|
||||
}
|
||||
|
||||
async function startWHEP(camId){
|
||||
try{
|
||||
const stream = await api(`/streams/camera/${camId}`);
|
||||
const video = document.getElementById('vid-'+camId);
|
||||
if(!video) return;
|
||||
|
||||
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.addTransceiver('audio',{direction:'recvonly'});
|
||||
|
||||
pc.ontrack = e => {video.srcObject = e.streams[0]};
|
||||
pc.oniceconnectionstatechange = ()=>{
|
||||
const status = document.getElementById('status-'+camId);
|
||||
if(status)status.className='cam-status '+(pc.iceConnectionState==='connected'?'online':'offline');
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const resp = await fetch(stream.whep_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
|
||||
if(!resp.ok) throw new Error('WHEP failed');
|
||||
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
|
||||
}catch(e){console.error('WHEP for cam',camId,e)}
|
||||
}
|
||||
|
||||
// === PHONE CAMERA INGEST (WHIP) ===
|
||||
document.getElementById('phoneStreamBtn').addEventListener('click',togglePhoneStream);
|
||||
|
||||
async function togglePhoneStream(){
|
||||
if(phoneStreaming){stopPhoneStream();return}
|
||||
|
||||
try{
|
||||
const stream = await navigator.mediaDevices.getUserMedia({video:{width:1280,height:720},audio:false});
|
||||
phoneStream = stream;
|
||||
const localVid = document.getElementById('phoneLocalVideo');
|
||||
localVid.srcObject = stream;
|
||||
document.getElementById('phonePreview').style.display='block';
|
||||
|
||||
const streamInfo = await api('/streams/phone');
|
||||
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
phonePC = pc;
|
||||
|
||||
stream.getTracks().forEach(t=>pc.addTrack(t,stream));
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const resp = await fetch(streamInfo.whip_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
|
||||
if(!resp.ok) throw new Error('WHIP failed: '+resp.status);
|
||||
|
||||
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
|
||||
|
||||
phoneStreaming = true;
|
||||
const btn = document.getElementById('phoneStreamBtn');
|
||||
btn.textContent = '⏹️ Stop Phone Camera';
|
||||
btn.classList.add('streaming');
|
||||
toast('📱 Phone camera streaming LIVE');
|
||||
}catch(e){toast('Error: '+e.message);console.error(e)}
|
||||
}
|
||||
|
||||
function stopPhoneStream(){
|
||||
if(phonePC){phonePC.close();phonePC=null}
|
||||
if(phoneStream){phoneStream.getTracks().forEach(t=>t.stop());phoneStream=null}
|
||||
phoneStreaming=false;
|
||||
const btn=document.getElementById('phoneStreamBtn');
|
||||
btn.textContent='📱 Start Phone Camera';
|
||||
btn.classList.remove('streaming');
|
||||
document.getElementById('phonePreview').style.display='none';
|
||||
toast('Phone camera stopped');
|
||||
}
|
||||
|
||||
// === PTZ ===
|
||||
async function ptz(camId,dir){
|
||||
try{await api(`/cameras/${camId}/ptz`,{method:'POST',body:JSON.stringify({direction:dir})})}
|
||||
catch(e){toast('PTZ: '+e.message)}
|
||||
}
|
||||
async function ptzZoom(camId,zin){
|
||||
try{await api(`/cameras/${camId}/ptz/zoom`,{method:'POST',body:JSON.stringify({zoom_in:zin})})}
|
||||
catch(e){toast('Zoom: '+e.message)}
|
||||
}
|
||||
async function snapshotCam(camId){
|
||||
try{await api(`/cameras/${camId}/snapshot`,{method:'POST'});toast('Snapshot saved')}
|
||||
catch(e){toast('Snapshot: '+e.message)}
|
||||
}
|
||||
async function deleteCam(camId){
|
||||
if(!confirm('Delete camera?'))return;
|
||||
try{await api(`/cameras/${camId}`,{method:'DELETE'});loadCameras();toast('Camera deleted')}
|
||||
catch(e){toast(e.message)}
|
||||
}
|
||||
|
||||
// === ADD CAMERA ===
|
||||
document.getElementById('addCamBtn').addEventListener('click',async()=>{
|
||||
const data={
|
||||
name:document.getElementById('camName').value,
|
||||
rtsp_url:document.getElementById('camRtsp').value,
|
||||
onvif_host:document.getElementById('camOnvifHost').value||null,
|
||||
onvif_port:parseInt(document.getElementById('camOnvifPort').value)||80,
|
||||
onvif_user:document.getElementById('camOnvifUser').value||null,
|
||||
onvif_pass:document.getElementById('camOnvifPass').value||null,
|
||||
};
|
||||
if(!data.name||!data.rtsp_url)return toast('Name and RTSP URL required');
|
||||
try{await api('/cameras/',{method:'POST',body:JSON.stringify(data)});loadCameras();toast('Camera added!')}
|
||||
catch(e){toast(e.message)}
|
||||
});
|
||||
|
||||
// === SETTINGS ===
|
||||
async function loadSettings(){
|
||||
const camList=document.getElementById('camList');
|
||||
try{
|
||||
const cams=await api('/cameras/');
|
||||
camList.innerHTML=cams.length?cams.map(c=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03)"><span>📹 ${esc(c.name)} <span style="color:var(--dim);font-size:.6rem">${esc(c.rtsp_url)}</span></span><button class="btn danger" onclick="deleteCam(${c.id})">Delete</button></div>`).join(''):'<p style="color:var(--dim)">No cameras</p>';
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function loadAlerts(){
|
||||
try{
|
||||
const alerts=await api('/alerts/?limit=50');
|
||||
const tbody=document.getElementById('alertTable');
|
||||
if(!alerts.length){tbody.innerHTML='<tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr>';return}
|
||||
tbody.innerHTML=alerts.map(a=>`<tr><td>${fmtTime(a.created_at)}</td><td>${esc(a.camera_name||'?')}</td><td><span class="badge human">${a.type}</span></td><td>${a.snapshot_url?`<img class="snap-thumb" src="${a.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td><td>${esc(a.channels_sent||'—')}</td></tr>`).join('');
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function loadPlates(){
|
||||
try{
|
||||
const plates=await api('/plates/?limit=50');
|
||||
const tbody=document.getElementById('plateTable');
|
||||
if(!plates.length){tbody.innerHTML='<tr><td colspan="6" style="color:var(--dim);text-align:center">No plates yet</td></tr>';return}
|
||||
tbody.innerHTML=plates.map(p=>`<tr><td>${fmtTime(p.created_at)}</td><td>${esc(p.camera_name||'?')}</td><td><strong>${esc(p.plate)}</strong></td><td><span class="badge ${p.event_type}">${p.event_type}</span></td><td>${p.confidence?Math.round(p.confidence*100)+'%':'—'}</td><td>${p.snapshot_url?`<img class="snap-thumb" src="${p.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td></tr>`).join('');
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function showImage(src){
|
||||
const ov=document.createElement('div');ov.className='modal-overlay';
|
||||
ov.innerHTML=`<img src="${src}">`;
|
||||
ov.addEventListener('click',()=>ov.remove());
|
||||
document.body.appendChild(ov);
|
||||
}
|
||||
|
||||
function fmtTime(iso){if(!iso)return'';const d=new Date(iso);return d.toLocaleString()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// === INIT ===
|
||||
loadCameras();
|
||||
setInterval(()=>{if(document.querySelector('#tab-live.active'))loadCameras()},15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user