Annotation canvas: - Mouse wheel: zoom centered on cursor (1×→20×) - Right-click drag or Space+drag: pan - Two-finger pinch-to-zoom on touch devices - Bbox coordinates computed in logical image space (zoom-invariant) - "Plein écran" button opens current frame in new tab at full res - Zoom % indicator + reset button Event detail lightbox: - Mouse wheel: zoom in/out (0.5×→20×) - Drag to pan when zoomed in - "Ouvrir en plein écran" link opens raw frame at native 4K in new tab - Zoom resets on image change or lightbox close Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
13 KiB
HTML
288 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CamWatch — {{ ev.time_str }}</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>tailwind.config = { darkMode: 'class' }</script>
|
||
<style>
|
||
body { background: #0f172a; color: #e2e8f0; }
|
||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||
.plate { font-family: monospace; letter-spacing: 0.15em; }
|
||
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
|
||
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||
.btn-dl { background: #166534; border: 1px solid #16a34a; color: #4ade80; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; }
|
||
.btn-dl:hover { background: #14532d; }
|
||
.frame-thumb { cursor: zoom-in; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.12s; }
|
||
.frame-thumb:hover { border-color: #60a5fa; }
|
||
.frame-thumb.active { border-color: #3b82f6; }
|
||
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||
|
||
/* Lightbox */
|
||
#lightbox { display:none; position:fixed; inset:0; z-index:50; background:rgba(0,0,0,0.92); align-items:center; justify-content:center; }
|
||
#lightbox.open { display:flex; }
|
||
#lightbox img { max-width:95vw; max-height:92vh; object-fit:contain; border-radius:4px; }
|
||
#lb-nav { position:absolute; bottom:16px; left:50%; transform:translateX(-50%); display:flex; gap:12px; align-items:center; }
|
||
#lb-close { position:absolute; top:12px; right:16px; font-size:1.8rem; color:#94a3b8; cursor:pointer; line-height:1; }
|
||
#lb-close:hover { color:#fff; }
|
||
#lb-label { color:#94a3b8; font-size:0.85rem; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">
|
||
|
||
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||
<a href="/" class="btn-ghost">← Retour</a>
|
||
<span class="text-xl">🚗</span>
|
||
<span class="font-bold flex-1">{{ ev.time_str }}</span>
|
||
<a href="/event/{{ ev.id }}/annotate" class="btn-ghost">✏ Annoter</a>
|
||
{% if ev.plate %}
|
||
{% if is_whitelisted %}
|
||
<form action="/whitelist/remove/{{ ev.plate }}" method="post">
|
||
<input type="hidden" name="back" value="/event/{{ ev.id }}">
|
||
<button type="submit" class="btn-ghost text-green-400 hover:text-green-300 hover:border-green-600">🛡 Connu ✓</button>
|
||
</form>
|
||
{% else %}
|
||
<form action="/whitelist/add" method="post">
|
||
<input type="hidden" name="plate" value="{{ ev.plate }}">
|
||
<input type="hidden" name="back" value="/event/{{ ev.id }}">
|
||
<button type="submit" class="btn-ghost hover:text-green-400 hover:border-green-700">🛡 Whitelister</button>
|
||
</form>
|
||
{% endif %}
|
||
{% endif %}
|
||
<form action="/event/{{ ev.id }}/delete" method="post"
|
||
onsubmit="return confirm('Supprimer cet événement ?')">
|
||
<button type="submit" class="btn-ghost text-red-400 hover:text-red-300 hover:border-red-500">✕ Supprimer</button>
|
||
</form>
|
||
</header>
|
||
|
||
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
||
|
||
<!-- Info + main image -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
||
<div class="card p-4 space-y-4">
|
||
<div>
|
||
<label>Plaque</label>
|
||
<div class="mt-1 mb-2">
|
||
{% if ev.plate %}
|
||
<span class="plate text-xl font-bold px-4 py-2 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
|
||
{% else %}
|
||
<span class="text-slate-500 italic text-sm">Non lue automatiquement</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if plate_crop %}
|
||
<div class="mb-2">
|
||
<label>Détection brute (4×)</label>
|
||
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;">
|
||
</div>
|
||
{% endif %}
|
||
{% if plate_ocr %}
|
||
<div class="mb-2">
|
||
<label>Image utilisée pour l'OCR</label>
|
||
<img src="/{{ plate_ocr }}" class="mt-1 rounded border border-blue-700 w-full" style="image-rendering:pixelated;">
|
||
</div>
|
||
{% endif %}
|
||
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
|
||
<input type="text" name="plate" value="{{ ev.plate or '' }}"
|
||
placeholder="Saisir plaque…"
|
||
class="flex-1 text-sm font-mono uppercase"
|
||
style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;border-radius:6px;padding:5px 8px;">
|
||
<button type="submit" class="text-xs px-3 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">✓</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div>
|
||
<label>Couleur</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<div class="w-7 h-7 rounded-full border-2 border-slate-600" style="background:{{ ev.color_hex or '#808080' }};"></div>
|
||
<span class="font-medium">{{ ev.color_name or '—' }}</span>
|
||
<span class="text-slate-600 text-xs font-mono">{{ ev.color_hex or '' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pt-2 border-t border-slate-700">
|
||
<label>Debug</label>
|
||
<div class="mt-1 space-y-0.5 text-xs font-mono text-slate-400">
|
||
<div>id: {{ ev.id[:8] }}…</div>
|
||
<div>frames: {{ frames|length }} (~{{ capture_total }} capturées)</div>
|
||
<div>clip: {% if ev.clip_path %}✓ {{ clip_size }}{% else %}—{% endif %}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if has_clip %}
|
||
<a href="/{{ ev.clip_path }}" download class="btn-dl w-full text-center">⬇ Télécharger le clip</a>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="md:col-span-2 card overflow-hidden">
|
||
<div class="bg-black relative cursor-zoom-in" style="aspect-ratio:16/9;"
|
||
onclick="openLightbox('/{{ ev.snapshot_path }}', 'Meilleure frame ★')">
|
||
<img id="main-img"
|
||
src="{% if ev.snapshot_path %}/{{ ev.snapshot_path }}{% endif %}"
|
||
class="w-full h-full object-contain">
|
||
<span class="absolute bottom-2 right-2 text-xs text-slate-500">🔍 cliquer pour agrandir</span>
|
||
</div>
|
||
<div class="px-3 py-2 text-xs text-slate-500 text-center" id="img-label">Meilleure frame ★</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Video clip -->
|
||
{% if has_clip %}
|
||
<div class="card p-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<label>Clip vidéo — {{ capture_duration }}s</label>
|
||
<a href="/{{ ev.clip_path }}" download class="btn-dl text-xs py-1 px-3">⬇ Télécharger</a>
|
||
</div>
|
||
<video controls preload="metadata" class="w-full rounded-lg" style="max-height:380px;background:#000;">
|
||
<source src="/{{ ev.clip_path }}" type="video/mp4">
|
||
<p class="text-slate-400 text-sm p-4">Lecture impossible dans le navigateur —
|
||
<a href="/{{ ev.clip_path }}" download class="text-blue-400 underline">télécharger le clip</a></p>
|
||
</video>
|
||
</div>
|
||
{% else %}
|
||
<div class="card p-4 text-slate-500 text-sm">
|
||
<label class="block mb-1">Clip vidéo</label>
|
||
{% if ev.clip_path %}Fichier introuvable{% else %}Événement antérieur à la fonctionnalité clip{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- All frames -->
|
||
{% if frames %}
|
||
<div class="card p-4">
|
||
<label class="block mb-3">
|
||
Toutes les frames — {{ frames|length }} extraites à {{ capture_fps }}fps, triées meilleure plaque en premier
|
||
</label>
|
||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-1.5">
|
||
{% for frame_path in frames %}
|
||
<div class="frame-thumb {% if loop.first %}active{% endif %}"
|
||
onclick="selectFrame(this, '/{{ frame_path }}', 'Frame #{{ loop.index }}{% if loop.first %} ★{% endif %}')"
|
||
ondblclick="openLightbox('/{{ frame_path }}', 'Frame #{{ loop.index }}')">
|
||
<img src="/{{ frame_path }}" class="w-full object-cover" style="aspect-ratio:16/9;" loading="lazy">
|
||
<div class="text-center text-xs text-slate-500 py-0.5 bg-slate-900 leading-none">
|
||
#{{ loop.index }}{% if loop.first %} ★{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<p class="text-xs text-slate-600 mt-2">Clic simple → aperçu | Double-clic → plein écran</p>
|
||
</div>
|
||
{% else %}
|
||
<div class="card p-4 text-slate-500 text-sm">
|
||
<label class="block mb-1">Frames</label>
|
||
Événement antérieur à la fonctionnalité frames
|
||
</div>
|
||
{% endif %}
|
||
|
||
</main>
|
||
|
||
<!-- Lightbox -->
|
||
<div id="lightbox" onclick="closeLightboxIfOutside(event)">
|
||
<span id="lb-close" onclick="closeLightbox()">×</span>
|
||
<div id="lb-zoom-hint" style="position:absolute;top:12px;left:50%;transform:translateX(-50%);color:#64748b;font-size:0.75rem;pointer-events:none;">
|
||
Molette pour zoomer · Glisser pour déplacer · <span id="lb-zoom-pct">100%</span>
|
||
· <a id="lb-fullres" href="#" target="_blank" style="color:#60a5fa;text-decoration:underline;">Ouvrir en plein écran</a>
|
||
</div>
|
||
<img id="lb-img" src="" alt="" style="transform-origin:center center;">
|
||
<div id="lb-nav">
|
||
<button onclick="lbPrev()" class="btn-ghost px-4">←</button>
|
||
<span id="lb-label" class="text-slate-400 text-sm"></span>
|
||
<button onclick="lbNext()" class="btn-ghost px-4">→</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let lbFrames = {{ frames | tojson }};
|
||
let lbIdx = 0;
|
||
|
||
// Lightbox zoom/pan state
|
||
let lbZ = 1, lbOx = 0, lbOy = 0, lbDrag = null;
|
||
|
||
function lbResetZoom() {
|
||
lbZ = 1; lbOx = 0; lbOy = 0;
|
||
const im = document.getElementById('lb-img');
|
||
im.style.transform = '';
|
||
im.style.cursor = '';
|
||
document.getElementById('lb-zoom-pct').textContent = '100%';
|
||
}
|
||
|
||
function lbApplyTransform() {
|
||
document.getElementById('lb-img').style.transform =
|
||
`translate(${lbOx}px,${lbOy}px) scale(${lbZ})`;
|
||
document.getElementById('lb-zoom-pct').textContent = Math.round(lbZ*100)+'%';
|
||
document.getElementById('lb-img').style.cursor = lbZ > 1 ? 'grab' : '';
|
||
}
|
||
|
||
document.getElementById('lightbox').addEventListener('wheel', e => {
|
||
if (!document.getElementById('lightbox').classList.contains('open')) return;
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.3 : 1/1.3;
|
||
lbZ = Math.max(0.5, Math.min(20, lbZ * factor));
|
||
lbApplyTransform();
|
||
}, { passive: false });
|
||
|
||
document.getElementById('lb-img').addEventListener('mousedown', e => {
|
||
if (lbZ <= 1) return;
|
||
e.stopPropagation();
|
||
lbDrag = [e.clientX - lbOx, e.clientY - lbOy];
|
||
document.getElementById('lb-img').style.cursor = 'grabbing';
|
||
});
|
||
document.addEventListener('mousemove', e => {
|
||
if (!lbDrag) return;
|
||
lbOx = e.clientX - lbDrag[0];
|
||
lbOy = e.clientY - lbDrag[1];
|
||
lbApplyTransform();
|
||
});
|
||
document.addEventListener('mouseup', () => {
|
||
if (lbDrag) { lbDrag = null; if (lbZ > 1) document.getElementById('lb-img').style.cursor = 'grab'; }
|
||
});
|
||
|
||
function openLightbox(src, label) {
|
||
lbResetZoom();
|
||
document.getElementById('lb-img').src = src;
|
||
document.getElementById('lb-label').textContent = label;
|
||
document.getElementById('lb-fullres').href = src;
|
||
const rel = src.startsWith('/') ? src.slice(1) : src;
|
||
lbIdx = lbFrames.indexOf(rel);
|
||
document.getElementById('lightbox').classList.add('open');
|
||
}
|
||
|
||
function closeLightbox() {
|
||
document.getElementById('lightbox').classList.remove('open');
|
||
lbResetZoom();
|
||
}
|
||
|
||
function closeLightboxIfOutside(e) {
|
||
if (e.target.id === 'lightbox') closeLightbox();
|
||
}
|
||
|
||
function lbGo(idx) {
|
||
if (lbFrames.length === 0) return;
|
||
lbIdx = ((idx % lbFrames.length) + lbFrames.length) % lbFrames.length;
|
||
lbResetZoom();
|
||
document.getElementById('lb-img').src = '/' + lbFrames[lbIdx];
|
||
document.getElementById('lb-fullres').href = '/' + lbFrames[lbIdx];
|
||
document.getElementById('lb-label').textContent = 'Frame #' + (lbIdx + 1) + (lbIdx === 0 ? ' ★' : '');
|
||
}
|
||
|
||
function lbPrev() { lbGo(lbIdx - 1); }
|
||
function lbNext() { lbGo(lbIdx + 1); }
|
||
|
||
function selectFrame(el, src, label) {
|
||
document.getElementById('main-img').src = src;
|
||
document.getElementById('main-img').parentElement.onclick = () => openLightbox(src, label);
|
||
document.getElementById('img-label').textContent = label;
|
||
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
|
||
el.classList.add('active');
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (!document.getElementById('lightbox').classList.contains('open')) return;
|
||
if (e.key === 'Escape') closeLightbox();
|
||
if (e.key === 'ArrowLeft') lbPrev();
|
||
if (e.key === 'ArrowRight') lbNext();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|