forked from dsutanto/zenai-kpc-python
Tidur
This commit is contained in:
1 parent
91786b07c1
commit
660b9abef4
4 files changed
+528
-182
No files matched your search
+29
-179
@@ -149,6 +149,17 @@
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: none; border: 1px solid var(--cell-border);
|
||||
border-radius: 8px; padding: 7px 12px; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
|
||||
color: var(--text-secondary); text-decoration: none;
|
||||
transition: border-color 0.3s, color 0.3s, background 0.3s;
|
||||
}
|
||||
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
|
||||
.nav-link.active { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* Section title */
|
||||
.section-title {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
|
||||
@@ -406,55 +417,6 @@
|
||||
}
|
||||
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Snapshot gallery */
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.snap-card {
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 12px;
|
||||
overflow: hidden; cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
.snap-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.snap-thumb {
|
||||
width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block;
|
||||
background: #000;
|
||||
}
|
||||
.snap-info { padding: 8px 10px; }
|
||||
.snap-info .si-top {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||
}
|
||||
.snap-kind {
|
||||
font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 8px;
|
||||
}
|
||||
.snap-kind.in { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||
.snap-kind.out { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
||||
.snap-kind.detect { background: rgba(0,240,255,0.10); color: var(--accent); }
|
||||
.snap-tid {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 11px; font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.snap-time { font-size: 9px; color: var(--text-secondary); }
|
||||
|
||||
.snap-lightbox {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 14px; max-width: 92vw; max-height: 90vh;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.snap-lightbox img {
|
||||
max-width: 88vw; max-height: 78vh; border-radius: 10px; display: block;
|
||||
}
|
||||
.snap-modal-meta {
|
||||
margin-top: 10px; font-size: 11px; color: var(--text-secondary); letter-spacing: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-toolbar input[type="date"] {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
background: var(--input-bg); color: var(--text-primary);
|
||||
@@ -645,6 +607,7 @@
|
||||
<span class="dot"></span>
|
||||
<span>{{ site_name }}</span>
|
||||
</div>
|
||||
<a class="nav-link" href="/snapshots" title="Snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -655,7 +618,7 @@
|
||||
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
|
||||
<div class="s-icon primary"><i class="fas fa-calendar-day"></i></div>
|
||||
<div class="s-val" id="today-count">--</div>
|
||||
<div class="s-label">Total Count Today</div>
|
||||
<div class="s-label">Diff Today (In − Out)</div>
|
||||
<div class="sub-cards">
|
||||
<div class="sub-card in">
|
||||
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
|
||||
@@ -671,7 +634,7 @@
|
||||
<span class="s-chip info">Yesterday</span>
|
||||
<div class="s-icon warm"><i class="fas fa-history"></i></div>
|
||||
<div class="s-val" id="yesterday-count">--</div>
|
||||
<div class="s-label">Total Count Yesterday</div>
|
||||
<div class="s-label">Diff Yesterday (In − Out)</div>
|
||||
<div class="sub-cards">
|
||||
<div class="sub-card in">
|
||||
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
|
||||
@@ -749,8 +712,8 @@
|
||||
<div class="qs-left">
|
||||
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
|
||||
<div>
|
||||
<div class="qs-label">Grand Total</div>
|
||||
<div class="qs-sub">All time</div>
|
||||
<div class="qs-label">Grand Diff</div>
|
||||
<div class="qs-sub">All time (In − Out)</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="qs-val" id="grand-total">--</span>
|
||||
@@ -805,7 +768,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:4px;">Date</th>
|
||||
<th>Total Count</th>
|
||||
<th>Diff (In − Out)</th>
|
||||
<th>In</th>
|
||||
<th>Out</th>
|
||||
<th style="padding-right:4px;">Status</th>
|
||||
@@ -818,47 +781,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots -->
|
||||
<div class="table-panel">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<span class="section-title" style="margin-bottom:0;">◉ Crossing Snapshots</span>
|
||||
<div style="font-size:10px;color:var(--text-secondary);letter-spacing:1px;">Detected & counted frames — correlate by track ID</div>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="chart-filters" id="snap-filters">
|
||||
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
||||
<button data-kind="in" onclick="loadSnapshots('in')">IN</button>
|
||||
<button data-kind="out" onclick="loadSnapshots('out')">OUT</button>
|
||||
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
||||
</div>
|
||||
<input type="date" id="snap-date-filter">
|
||||
<button class="btn btn-primary" onclick="applySnapFilter()">FILTER</button>
|
||||
<button class="btn btn-ghost" onclick="resetSnapFilter()">RESET</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshot-grid" class="snapshot-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="snapshot-empty" style="display:none;text-align:center;padding:32px;color:var(--text-secondary);">
|
||||
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">▣</div>
|
||||
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
|
||||
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Edge Nano Counter by ZenAi</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot lightbox -->
|
||||
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
|
||||
<div class="snap-lightbox" id="snap-lightbox">
|
||||
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">×</button>
|
||||
<img id="snap-modal-img" alt="Snapshot" />
|
||||
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Theme ---
|
||||
(function() {
|
||||
@@ -893,7 +818,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadChartData(7);
|
||||
loadRecentDays();
|
||||
resetFilter();
|
||||
loadSnapshots('all');
|
||||
|
||||
setInterval(() => {
|
||||
loadSummary();
|
||||
@@ -903,10 +827,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setInterval(() => {
|
||||
loadCurrentCounter();
|
||||
}, 2000);
|
||||
|
||||
setInterval(() => {
|
||||
if (snapCurrentKind && !snapDateFilter) loadSnapshots(snapCurrentKind, true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
async function loadCurrentCounter() {
|
||||
@@ -915,9 +835,11 @@ async function loadCurrentCounter() {
|
||||
if (!res.ok) throw new Error('No active counter');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('today-count').textContent = (data.count || 0).toLocaleString();
|
||||
document.getElementById('today-in').textContent = (data.count_in || 0).toLocaleString();
|
||||
document.getElementById('today-out').textContent = (data.count_out || 0).toLocaleString();
|
||||
const tin = data.count_in || 0;
|
||||
const tout = data.count_out || 0;
|
||||
document.getElementById('today-count').textContent = (tin - tout).toLocaleString();
|
||||
document.getElementById('today-in').textContent = tin.toLocaleString();
|
||||
document.getElementById('today-out').textContent = tout.toLocaleString();
|
||||
} catch (err) {
|
||||
document.getElementById('today-count').textContent = '0';
|
||||
document.getElementById('today-in').textContent = '0';
|
||||
@@ -932,7 +854,7 @@ async function loadSummary() {
|
||||
|
||||
document.getElementById('current-date').textContent = data.today.date;
|
||||
|
||||
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString();
|
||||
document.getElementById('yesterday-count').textContent = (data.yesterday.total_in - data.yesterday.total_out).toLocaleString();
|
||||
document.getElementById('yesterday-in').textContent = data.yesterday.total_in.toLocaleString();
|
||||
document.getElementById('yesterday-out').textContent = data.yesterday.total_out.toLocaleString();
|
||||
|
||||
@@ -942,7 +864,7 @@ async function loadSummary() {
|
||||
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
|
||||
document.getElementById('best-date').textContent = data.best_day.date || '--';
|
||||
|
||||
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString();
|
||||
document.getElementById('grand-total').textContent = (data.all_time.grand_in - data.all_time.grand_out).toLocaleString();
|
||||
document.getElementById('grand-in').textContent = data.all_time.grand_in.toLocaleString();
|
||||
document.getElementById('grand-out').textContent = data.all_time.grand_out.toLocaleString();
|
||||
|
||||
@@ -965,7 +887,7 @@ async function loadChartData(days) {
|
||||
const date = new Date(d.date);
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
});
|
||||
const counts = data.map(d => d.total_count);
|
||||
const counts = data.map(d => d.total_in - d.total_out);
|
||||
const ins = data.map(d => d.total_in);
|
||||
const outs = data.map(d => d.total_out);
|
||||
|
||||
@@ -1001,7 +923,7 @@ async function loadChartData(days) {
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Total',
|
||||
label: 'Diff',
|
||||
data: counts,
|
||||
type: 'line',
|
||||
borderColor: 'rgba(0, 240, 255, 1)',
|
||||
@@ -1072,7 +994,7 @@ async function loadRecentDays() {
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="ri-count">${d.total_count.toLocaleString()}</div>
|
||||
<div class="ri-count">${(d.total_in - d.total_out).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -1125,7 +1047,7 @@ function renderDailyTable(dates) {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${d.total_count.toLocaleString()}</span></td>
|
||||
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${(d.total_in - d.total_out).toLocaleString()}</span></td>
|
||||
<td><span class="badge badge-green">${d.total_in.toLocaleString()}</span></td>
|
||||
<td><span class="badge badge-gray">${d.total_out.toLocaleString()}</span></td>
|
||||
<td style="padding-right:4px;">
|
||||
@@ -1156,81 +1078,9 @@ function formatTime(isoString) {
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { hideVideoError(); closeSnapModal(); }
|
||||
if (e.key === 'Escape') { hideVideoError(); }
|
||||
});
|
||||
|
||||
// --- Crossing Snapshots ---
|
||||
let snapCurrentKind = 'all';
|
||||
let snapDateFilter = '';
|
||||
|
||||
async function loadSnapshots(kind, silent) {
|
||||
if (kind !== undefined) snapCurrentKind = kind;
|
||||
document.querySelectorAll('#snap-filters button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.kind === snapCurrentKind);
|
||||
});
|
||||
|
||||
try {
|
||||
let url = `/api/snapshots?kind=${snapCurrentKind}&limit=120`;
|
||||
if (snapDateFilter) url += `&date=${snapDateFilter}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('snapshot-grid');
|
||||
const empty = document.getElementById('snapshot-empty');
|
||||
const items = (data && data.snapshots) || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
grid.innerHTML = items.map(s => {
|
||||
const url = `/api/snapshot-image/${s.file}`;
|
||||
const t = new Date(s.timestamp);
|
||||
const timeStr = t.toLocaleString('en-US', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const kindLabel = s.kind.toUpperCase();
|
||||
return `
|
||||
<div class="snap-card" onclick="openSnapModal('${url}','${kindLabel}',${s.track_id},'${timeStr}',${s.frame})">
|
||||
<img class="snap-thumb" src="${url}" loading="lazy" alt="snapshot" />
|
||||
<div class="snap-info">
|
||||
<div class="si-top">
|
||||
<span class="snap-kind ${s.kind}">${kindLabel}</span>
|
||||
<span class="snap-tid">ID ${s.track_id}</span>
|
||||
</div>
|
||||
<div class="snap-time">${timeStr}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
if (!silent) console.error('Failed to load snapshots:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapFilter() {
|
||||
snapDateFilter = document.getElementById('snap-date-filter').value || '';
|
||||
loadSnapshots(snapCurrentKind);
|
||||
}
|
||||
|
||||
function resetSnapFilter() {
|
||||
snapDateFilter = '';
|
||||
document.getElementById('snap-date-filter').value = '';
|
||||
snapCurrentKind = 'all';
|
||||
loadSnapshots('all');
|
||||
}
|
||||
|
||||
function openSnapModal(url, kind, tid, timeStr, frame) {
|
||||
document.getElementById('snap-modal-img').src = url;
|
||||
document.getElementById('snap-modal-meta').textContent =
|
||||
`${kind} \u00b7 Track ID ${tid} \u00b7 Frame ${frame} \u00b7 ${timeStr}`;
|
||||
document.getElementById('snap-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeSnapModal(event) {
|
||||
if (event && event.target && event.target.id !== 'snap-modal' && event.type === 'click') return;
|
||||
document.getElementById('snap-modal').classList.add('hidden');
|
||||
document.getElementById('snap-modal-img').src = '';
|
||||
}
|
||||
|
||||
// --- Live Video Feed ---
|
||||
|
||||
function toggleVideo() {
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZenAI KPC Snapshots</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-deep: #06080e;
|
||||
--bg-surface: rgba(12, 16, 28, 0.9);
|
||||
--border-glow: rgba(0, 240, 255, 0.12);
|
||||
--accent: #00f0ff;
|
||||
--accent2: #7b2fff;
|
||||
--accent3: #ff2d78;
|
||||
--accent4: #00ff88;
|
||||
--text-primary: #dce2f0;
|
||||
--text-secondary: #6b7394;
|
||||
--glass: rgba(12, 16, 32, 0.7);
|
||||
--cell-bg: rgba(255, 255, 255, 0.02);
|
||||
--cell-border: rgba(255, 255, 255, 0.05);
|
||||
--divider: rgba(255, 255, 255, 0.05);
|
||||
--input-bg: rgba(0, 0, 0, 0.4);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-deep: #eef1f5;
|
||||
--bg-surface: rgba(255, 255, 255, 0.9);
|
||||
--border-glow: rgba(0, 160, 200, 0.15);
|
||||
--accent: #0090c0;
|
||||
--accent2: #6a20e0;
|
||||
--accent3: #d02060;
|
||||
--accent4: #00a060;
|
||||
--text-primary: #1a1d30;
|
||||
--text-secondary: #5a6088;
|
||||
--glass: rgba(255, 255, 255, 0.8);
|
||||
--cell-bg: rgba(0, 0, 0, 0.03);
|
||||
--cell-border: rgba(0, 0, 0, 0.06);
|
||||
--divider: rgba(0, 0, 0, 0.06);
|
||||
--input-bg: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
|
||||
}
|
||||
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
|
||||
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; }
|
||||
|
||||
.container {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 28px; margin-bottom: 24px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.logo-icon {
|
||||
width: 44px; height: 44px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
border-radius: 11px; font-size: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 28px rgba(0, 240, 255, 0.35);
|
||||
}
|
||||
.header h1 {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.header-right { display: flex; align-items: center; gap: 14px; }
|
||||
.live-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
.live-chip .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: none; border: 1px solid var(--cell-border);
|
||||
border-radius: 8px; padding: 7px 12px; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
|
||||
color: var(--text-secondary); text-decoration: none;
|
||||
transition: border-color 0.3s, color 0.3s, background 0.3s;
|
||||
}
|
||||
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
|
||||
.nav-link.active { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.theme-toggle {
|
||||
background: none; border: 1px solid var(--cell-border);
|
||||
border-radius: 8px; padding: 6px 10px; cursor: pointer;
|
||||
font-size: 16px; line-height: 1;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
|
||||
|
||||
.section-title {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 24px 26px; margin-bottom: 24px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.filters { display: flex; gap: 6px; }
|
||||
.filters button {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px;
|
||||
letter-spacing: 1px; padding: 6px 14px; border-radius: 7px;
|
||||
border: 1px solid var(--cell-border);
|
||||
background: var(--cell-bg); color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
}
|
||||
.filters button:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
.filters button.active {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #000; border-color: transparent; font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="date"] {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
background: var(--input-bg); color: var(--text-primary);
|
||||
border: 1px solid var(--cell-border); border-radius: 7px;
|
||||
padding: 7px 12px; outline: none; transition: border-color 0.3s;
|
||||
}
|
||||
input[type="date"]:focus { border-color: var(--accent); }
|
||||
|
||||
.btn {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
||||
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
||||
border: none; transition: opacity 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; }
|
||||
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
|
||||
.btn-ghost {
|
||||
background: var(--cell-bg); color: var(--text-secondary);
|
||||
border: 1px solid var(--cell-border);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.snap-card {
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 12px;
|
||||
overflow: hidden; cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
.snap-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.snap-thumb {
|
||||
width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: #000;
|
||||
}
|
||||
.snap-info { padding: 8px 10px; }
|
||||
.snap-info .si-top {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||
}
|
||||
.snap-kind {
|
||||
font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 8px;
|
||||
}
|
||||
.snap-kind.in { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||
.snap-kind.out { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
||||
.snap-kind.detect { background: rgba(0,240,255,0.10); color: var(--accent); }
|
||||
.snap-tid {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 11px; font-weight: 700; color: var(--text-primary);
|
||||
}
|
||||
.snap-time { font-size: 9px; color: var(--text-secondary); }
|
||||
|
||||
.empty {
|
||||
text-align: center; padding: 48px; color: var(--text-secondary);
|
||||
}
|
||||
.empty .e-icon { font-size: 42px; opacity: 0.2; margin-bottom: 12px; }
|
||||
|
||||
.count-note { font-size: 10px; color: var(--text-secondary); letter-spacing: 1px; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.modal-overlay.hidden { display: none; }
|
||||
.close-btn {
|
||||
background: none; border: 1px solid var(--cell-border); color: var(--text-secondary);
|
||||
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color 0.3s, color 0.3s;
|
||||
}
|
||||
.close-btn:hover { border-color: var(--accent3); color: var(--accent3); }
|
||||
.snap-lightbox {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 14px; max-width: 92vw; max-height: 90vh;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.snap-lightbox img { max-width: 88vw; max-height: 78vh; border-radius: 10px; display: block; }
|
||||
.snap-modal-meta {
|
||||
margin-top: 10px; font-size: 11px; color: var(--text-secondary); letter-spacing: 1px; text-align: center;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
|
||||
|
||||
.footer {
|
||||
margin-top: 24px; text-align: center;
|
||||
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary); text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 16px 12px; }
|
||||
.header { flex-direction: column; gap: 10px; padding: 14px 20px; }
|
||||
.header-right { flex-wrap: wrap; justify-content: center; }
|
||||
.toolbar { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo-icon">◆</div>
|
||||
<div>
|
||||
<h1>ZenAI KPC Snapshots</h1>
|
||||
<div class="header-sub">Detected & counted frames — correlate by track ID</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="live-chip">
|
||||
<span class="dot"></span>
|
||||
<span>{{ site_name }}</span>
|
||||
</div>
|
||||
<a class="nav-link" href="/"><i class="fas fa-gauge-high"></i> DASHBOARD</a>
|
||||
<a class="nav-link active" href="/snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots -->
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<span class="section-title">◉ Crossing Snapshots</span>
|
||||
<div class="count-note" id="snap-count-note">--</div>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="filters" id="snap-filters">
|
||||
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
||||
<button data-kind="in" onclick="loadSnapshots('in')">IN</button>
|
||||
<button data-kind="out" onclick="loadSnapshots('out')">OUT</button>
|
||||
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
||||
</div>
|
||||
<input type="number" id="snap-tid-filter" placeholder="Track ID" min="0"
|
||||
style="width:110px;font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--input-bg);color:var(--text-primary);border:1px solid var(--cell-border);border-radius:7px;padding:7px 12px;outline:none;">
|
||||
<input type="date" id="snap-date-filter">
|
||||
<button class="btn btn-primary" onclick="applySnapFilter()">FILTER</button>
|
||||
<button class="btn btn-ghost" onclick="resetSnapFilter()">RESET</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshot-grid" class="snapshot-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="snap-sentinel" style="height:1px;"></div>
|
||||
<div id="snapshot-empty" class="empty" style="display:none;">
|
||||
<div class="e-icon">▣</div>
|
||||
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
|
||||
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Edge Nano Counter by ZenAi</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot lightbox -->
|
||||
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
|
||||
<div class="snap-lightbox" id="snap-lightbox">
|
||||
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">×</button>
|
||||
<img id="snap-modal-img" alt="Snapshot" />
|
||||
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Theme ---
|
||||
(function() {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||
updateToggleIcon();
|
||||
})();
|
||||
|
||||
function toggleTheme() {
|
||||
const el = document.documentElement;
|
||||
const current = el.getAttribute('data-theme');
|
||||
const next = current === 'light' ? '' : 'light';
|
||||
if (next) el.setAttribute('data-theme', next);
|
||||
else el.removeAttribute('data-theme');
|
||||
localStorage.setItem('theme', next || 'dark');
|
||||
updateToggleIcon();
|
||||
}
|
||||
|
||||
function updateToggleIcon() {
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (!btn) return;
|
||||
btn.textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
|
||||
// --- Snapshots ---
|
||||
let snapCurrentKind = 'all';
|
||||
let snapDateFilter = '';
|
||||
let snapTidFilter = '';
|
||||
|
||||
let snapItems = []; // all fetched metadata
|
||||
let snapRendered = 0; // how many cards are in the DOM
|
||||
const SNAP_BATCH = 60; // cards added per chunk
|
||||
let snapObserver = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sentinel = document.getElementById('snap-sentinel');
|
||||
snapObserver = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) renderNextBatch();
|
||||
}, { rootMargin: '600px' });
|
||||
if (sentinel) snapObserver.observe(sentinel);
|
||||
|
||||
// Filter immediately when date changes or track ID is typed.
|
||||
document.getElementById('snap-date-filter').addEventListener('change', applySnapFilter);
|
||||
let tidDebounce = null;
|
||||
document.getElementById('snap-tid-filter').addEventListener('input', () => {
|
||||
clearTimeout(tidDebounce);
|
||||
tidDebounce = setTimeout(applySnapFilter, 350);
|
||||
});
|
||||
|
||||
loadSnapshots('all');
|
||||
setInterval(() => {
|
||||
if (snapCurrentKind && !snapDateFilter && !snapTidFilter) loadSnapshots(snapCurrentKind, true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
function snapCardHtml(s) {
|
||||
const imgUrl = `/api/snapshot-image/${s.file}`;
|
||||
const t = new Date(s.timestamp);
|
||||
const timeStr = t.toLocaleString('en-US', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const kindLabel = s.kind.toUpperCase();
|
||||
return `
|
||||
<div class="snap-card" onclick="openSnapModal('${imgUrl}','${kindLabel}',${s.track_id},'${timeStr}',${s.frame})">
|
||||
<img class="snap-thumb" src="${imgUrl}" loading="lazy" decoding="async" alt="snapshot" />
|
||||
<div class="snap-info">
|
||||
<div class="si-top">
|
||||
<span class="snap-kind ${s.kind}">${kindLabel}</span>
|
||||
<span class="snap-tid">ID ${s.track_id}</span>
|
||||
</div>
|
||||
<div class="snap-time">${timeStr}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNextBatch() {
|
||||
if (snapRendered >= snapItems.length) return;
|
||||
const grid = document.getElementById('snapshot-grid');
|
||||
const next = snapItems.slice(snapRendered, snapRendered + SNAP_BATCH);
|
||||
grid.insertAdjacentHTML('beforeend', next.map(snapCardHtml).join(''));
|
||||
snapRendered += next.length;
|
||||
updateSnapNote();
|
||||
}
|
||||
|
||||
function updateSnapNote() {
|
||||
const note = document.getElementById('snap-count-note');
|
||||
note.textContent = `Showing ${snapRendered} of ${snapItems.length}`;
|
||||
}
|
||||
|
||||
async function loadSnapshots(kind, silent) {
|
||||
if (kind !== undefined) snapCurrentKind = kind;
|
||||
document.querySelectorAll('#snap-filters button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.kind === snapCurrentKind);
|
||||
});
|
||||
|
||||
try {
|
||||
let url = `/api/snapshots?kind=${snapCurrentKind}&limit=5000`;
|
||||
if (snapDateFilter) url += `&date=${snapDateFilter}`;
|
||||
if (snapTidFilter) url += `&track_id=${snapTidFilter}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('snapshot-grid');
|
||||
const empty = document.getElementById('snapshot-empty');
|
||||
|
||||
snapItems = (data && data.snapshots) || [];
|
||||
snapRendered = 0;
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (snapItems.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
document.getElementById('snap-count-note').textContent = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
renderNextBatch();
|
||||
} catch (err) {
|
||||
if (!silent) console.error('Failed to load snapshots:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapFilter() {
|
||||
snapDateFilter = document.getElementById('snap-date-filter').value || '';
|
||||
snapTidFilter = document.getElementById('snap-tid-filter').value || '';
|
||||
loadSnapshots(snapCurrentKind);
|
||||
}
|
||||
|
||||
function resetSnapFilter() {
|
||||
snapDateFilter = '';
|
||||
snapTidFilter = '';
|
||||
document.getElementById('snap-date-filter').value = '';
|
||||
document.getElementById('snap-tid-filter').value = '';
|
||||
snapCurrentKind = 'all';
|
||||
loadSnapshots('all');
|
||||
}
|
||||
|
||||
function openSnapModal(url, kind, tid, timeStr, frame) {
|
||||
document.getElementById('snap-modal-img').src = url;
|
||||
document.getElementById('snap-modal-meta').textContent =
|
||||
`${kind} \u00b7 Track ID ${tid} \u00b7 Frame ${frame} \u00b7 ${timeStr}`;
|
||||
document.getElementById('snap-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeSnapModal(event) {
|
||||
if (event && event.target && event.target.id !== 'snap-modal' && event.type === 'click') return;
|
||||
document.getElementById('snap-modal').classList.add('hidden');
|
||||
document.getElementById('snap-modal-img').src = '';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeSnapModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user