Files
imagod-web/src/frontend/index.html
T
bruno f25174f143 feat: upload drag-drop + responsive mobile
Backend:
- Route POST /api/upload avec multipart (axum 0.8)
- Proxy vers Imago, validation MIME, feedback JSON

Frontend:
- Zone de drop avec drag & drop (effet visuel au survol)
- Bouton Upload dans le header + FAB flottant mobile
- Support Ctrl+V (coller depuis presse-papier)
- Upload multiple, feedback toast (succès/erreur)
- Raccourci Ctrl+U pour upload
- Responsive: 2 breakpoints (768px, 400px)
- Gallery 2 colonnes sur mobile, FAB, toasts bottom
- user-scalable=no, tap-highlight transparent
2026-06-23 10:31:02 -04:00

632 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Imago Dashboard</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-hover: #1c2129;
--border: #30363d;
--fg: #c9d1d9;
--fg-dim: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--radius: 8px;
--shadow: 0 4px 24px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
line-height: 1.5;
-webkit-tap-highlight-color: transparent;
}
/* ─── Header ─── */
.header {
position: sticky; top: 0; z-index: 100;
background: rgba(13,17,23,0.92); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex; align-items: center; gap: 16px;
flex-wrap: wrap;
}
.header .logo { font-size: 20px; font-weight: 700; color: var(--accent); }
.header .logo span { font-weight: 400; color: var(--fg-dim); margin-left: 4px; }
.ws-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.ws-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.ws-dot.off { background: var(--red); }
.ws-status { font-size: 12px; color: var(--fg-dim); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
/* ─── Search ─── */
.search-bar { display: flex; gap: 8px; flex: 1; min-width: 180px; }
.search-bar input {
flex: 1; padding: 8px 14px; border-radius: var(--radius);
background: var(--bg-card); border: 1px solid var(--border);
color: var(--fg); font-size: 14px; outline: none; min-width: 0;
}
.search-bar input:focus { border-color: var(--accent); }
.search-bar input::placeholder { color: var(--fg-dim); }
.btn {
padding: 8px 16px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--bg-card); color: var(--fg); cursor: pointer;
font-size: 13px; font-weight: 500; white-space: nowrap;
transition: all 0.15s; user-select: none;
}
.btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent-hover); }
.btn.accent { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.accent:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
/* ─── Layout ─── */
.main { max-width: 1400px; margin: 0 auto; padding: 24px; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 20px;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0;
width: 3px; height: 100%; background: var(--accent);
}
.stat-label { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 28px; font-weight: 700; margin-top: 4px; color: var(--fg); }
/* ─── Drop Zone ─── */
.drop-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 32px 24px; margin-bottom: 24px;
text-align: center; cursor: pointer;
transition: all 0.2s; position: relative;
background: var(--bg-card);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--accent); background: rgba(88,166,255,0.05);
}
.drop-zone.drag-over { border-style: solid; }
.drop-zone-icon { font-size: 40px; margin-bottom: 8px; opacity: 0.6; }
.drop-zone-text { font-size: 15px; color: var(--fg); margin-bottom: 4px; }
.drop-zone-hint { font-size: 12px; color: var(--fg-dim); }
.drop-zone input[type="file"] { display: none; }
.drop-zone.uploading {
pointer-events: none; opacity: 0.6;
border-color: var(--yellow);
}
.drop-zone.uploading .drop-zone-text { color: var(--yellow); }
/* ─── Toast ─── */
.toast-container {
position: fixed; top: 80px; right: 24px; z-index: 300;
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
}
.toast {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 16px;
box-shadow: var(--shadow); display: flex; align-items: center; gap: 10px;
animation: toast-in 0.25s ease-out;
font-size: 14px;
}
.toast.success { border-left: 3px solid var(--green); }
.toast.error { border-left: 3px solid var(--red); }
.toast.info { border-left: 3px solid var(--accent); }
.toast-msg { flex: 1; }
.toast-close {
background: none; border: none; color: var(--fg-dim);
cursor: pointer; font-size: 18px; line-height: 1; padding: 0 4px;
}
@keyframes toast-in { from { opacity:0; transform:translateX(40px); } to { opacity:1; transform:translateX(0); } }
/* ─── Gallery ─── */
.section-title { font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: var(--fg-dim); display: flex; align-items: center; gap: 8px; }
.gallery {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.gallery.empty { display: flex; flex-direction: column; align-items: center; padding: 48px 24px; }
.gallery.empty p { color: var(--fg-dim); margin-top: 8px; }
.gallery.empty .icon { font-size: 48px; opacity: 0.35; }
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden; cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: var(--shadow); }
.card:active { transform: scale(0.98); }
.card-thumb {
width: 100%; aspect-ratio: 16/10; object-fit: cover;
display: block; background: #161b22;
}
.card-thumb.placeholder {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
aspect-ratio: 16/10;
background: #161b22; color: var(--fg-dim); font-size: 32px;
text-align: center; padding: 16px; gap: 4px;
}
.card-thumb.placeholder small { font-size: 12px; color: #484f58; }
.card-body { padding: 10px 12px; }
.card-filename { font-size: 13px; font-weight: 500; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-meta { font-size: 11px; color: var(--fg-dim); margin-top: 4px; display: flex; align-items: center; gap: 8px; }
.card-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 12px; background: rgba(88,166,255,0.12); color: var(--accent); white-space: nowrap; }
.pagination { display: flex; gap: 8px; justify-content: center; margin-top: 24px; align-items: center; flex-wrap: wrap; }
.pagination span { font-size: 13px; color: var(--fg-dim); }
/* ─── Modal ─── */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 200; display: none; align-items: center; justify-content: center; padding: 24px; }
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 16px; max-width: 900px; width: 100%; max-height: 90vh;
overflow-y: auto; box-shadow: var(--shadow); position: relative;
}
.modal-close { position: absolute; top: 12px; right: 16px; font-size: 24px; color: var(--fg-dim); cursor: pointer; z-index: 1; background: none; border: none; }
.modal-close:hover { color: var(--fg); }
.modal img { width: 100%; display: block; border-radius: 12px 12px 0 0; }
.modal .modal-placeholder {
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
background: #161b22; border-radius: 12px 12px 0 0; color: var(--fg-dim);
font-size: 48px;
}
.modal-details { padding: 20px; }
.modal-details h2 { font-size: 18px; margin-bottom: 8px; }
.modal-details .desc { color: var(--fg-dim); font-size: 14px; line-height: 1.6; margin-bottom: 12px; }
.modal-details .ocr { background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 12px; font-size: 12px; color: var(--fg-dim); font-family: monospace; max-height: 120px; overflow-y: auto; white-space: pre-wrap; }
.modal-details table { width: 100%; font-size: 13px; margin-top: 12px; }
.modal-details td { padding: 4px 8px; border-bottom: 1px solid var(--border); }
.modal-details td:first-child { color: var(--fg-dim); width: 100px; }
/* ─── Spinner ─── */
.spinner {
width: 32px; height: 32px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.6s linear infinite; margin: 32px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Floating Upload (mobile) ─── */
.fab-upload {
display: none; position: fixed; bottom: 24px; right: 24px; z-index: 150;
width: 56px; height: 56px; border-radius: 50%; background: var(--accent);
color: #fff; font-size: 28px; border: none; cursor: pointer;
box-shadow: 0 4px 16px rgba(88,166,255,0.4);
transition: transform 0.15s, box-shadow 0.15s;
align-items: center; justify-content: center;
}
.fab-upload:active { transform: scale(0.9); }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.header { padding: 10px 14px; gap: 10px; }
.header .logo { font-size: 17px; }
.header .logo span { display: none; }
.main { padding: 14px; }
.gallery { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; }
.stats-row { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stat-value { font-size: 22px; }
.stat-card { padding: 12px 14px; }
.drop-zone { padding: 24px 16px; }
.drop-zone-icon { font-size: 32px; }
.drop-zone-text { font-size: 14px; }
.search-bar { order: 10; flex-basis: 100%; min-width: 100%; }
.ws-status { order: 5; }
.toast-container { top: auto; bottom: 16px; right: 12px; left: 12px; max-width: none; }
.fab-upload { display: flex; }
.pagination { gap: 4px; }
.pagination .btn { padding: 6px 10px; font-size: 12px; }
.modal { border-radius: 12px; max-height: 85vh; }
.modal-details { padding: 14px; }
.modal-details h2 { font-size: 16px; }
.card-body { padding: 8px 10px; }
.card-filename { font-size: 12px; }
}
@media (max-width: 400px) {
.gallery { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stats-row { grid-template-columns: 1fr 1fr; gap: 8px; }
.header { padding: 8px 10px; gap: 8px; }
.header .logo { font-size: 15px; }
.btn { padding: 6px 10px; font-size: 12px; }
.search-bar input { padding: 6px 10px; font-size: 13px; }
}
</style>
</head>
<body>
<!-- ─── Header ─── -->
<div class="header">
<div class="logo">🖼️ Imago<span>Dashboard</span></div>
<div class="ws-status" id="ws-status">
<span class="ws-dot off" id="ws-dot"></span>
<span id="ws-label">Déconnecté</span>
</div>
<div class="search-bar">
<input type="text" id="search" placeholder="Rechercher par texte, tag, description…" autocomplete="off" enterkeyhint="search">
<button class="btn" id="search-btn">🔍</button>
<button class="btn" id="clear-btn" style="display:none">✕</button>
</div>
<button class="btn accent" id="header-upload-btn" onclick="document.getElementById('drop-zone').scrollIntoView({behavior:'smooth'}); document.getElementById('file-input').click()">📤 Upload</button>
</div>
<!-- ─── Toast Container ─── -->
<div class="toast-container" id="toast-container"></div>
<!-- ─── Main Content ─── -->
<div class="main">
<!-- Stats -->
<div class="stats-row" id="stats-row">
<div class="stat-card"><div class="stat-label">Images</div><div class="stat-value" id="stat-images">…</div></div>
<div class="stat-card"><div class="stat-label">Stockage</div><div class="stat-value" id="stat-storage">…</div></div>
<div class="stat-card"><div class="stat-label">Tags</div><div class="stat-value" id="stat-tags">…</div></div>
<div class="stat-card"><div class="stat-label">En ligne</div><div class="stat-value" id="stat-uptime" style="color:var(--green)">●</div></div>
</div>
<!-- Drop Zone -->
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-zone-icon">📁</div>
<div class="drop-zone-text">Glissez-déposez des images ici</div>
<div class="drop-zone-hint">ou cliquez pour sélectionner — PNG, JPG, GIF, WebP</div>
<input type="file" id="file-input" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp,image/tiff" multiple>
</div>
<!-- Gallery -->
<div class="section-title" id="gallery-title">🖼️ Captures récentes</div>
<div class="gallery" id="gallery"></div>
<div class="pagination" id="pagination"></div>
<div class="no-results" id="no-results" style="display:none">
<div style="font-size:48px;opacity:0.35">🔍</div>
<p>Aucun résultat trouvé</p>
</div>
</div>
<!-- ─── Modal ─── -->
<div class="modal-overlay" id="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<!-- ─── Floating Upload (mobile) ─── -->
<button class="fab-upload" id="fab-upload" onclick="document.getElementById('file-input').click()" title="Upload">+</button>
<script>
// ─── State ───
let currentPage = 1;
let currentQuery = '';
let ws = null;
// ─── API Helpers ───
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) {
const body = await r.text();
let msg = `HTTP ${r.status}`;
try { msg = JSON.parse(body).error || msg; } catch(_) {}
throw new Error(msg);
}
return r.json();
}
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
const u = ['B','KB','MB','GB']; let i = 0;
while (b >= 1024 && i < 3) { b /= 1024; i++; }
return `${b.toFixed(i ? 1 : 0)} ${u[i]}`;
}
function fmtDate(s) {
if (!s) return '';
const d = new Date(s);
return d.toLocaleDateString('fr-CA', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' });
}
// ─── Toast ───
function showToast(msg, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span class="toast-msg">${escHtml(msg)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
container.appendChild(toast);
if (duration > 0) setTimeout(() => { if (toast.parentElement) toast.remove(); }, duration);
}
// ─── Upload ───
async function uploadFiles(files) {
if (!files || files.length === 0) return;
const zone = document.getElementById('drop-zone');
let successCount = 0, errorCount = 0;
for (const file of files) {
if (!file.type.startsWith('image/')) {
showToast(`${file.name} : type non supporté`, 'error');
errorCount++;
continue;
}
zone.classList.add('uploading');
zone.querySelector('.drop-zone-text').textContent = `Upload : ${file.name}...`;
try {
const form = new FormData();
form.append('file', file);
const r = await fetch('/api/upload', { method: 'POST', body: form });
const data = await r.json();
if (r.ok) {
successCount++;
showToast(`${file.name} uploadé !`, 'success', 3000);
} else {
errorCount++;
showToast(`${file.name} : ${data.error || 'Échec'}`, 'error');
}
} catch(e) {
errorCount++;
showToast(`${file.name} : ${e.message}`, 'error');
}
}
zone.classList.remove('uploading');
zone.querySelector('.drop-zone-text').textContent = 'Glissez-déposez des images ici';
if (successCount > 0) {
document.getElementById('file-input').value = '';
loadImages(1);
loadStats();
if (successCount === 1 && files.length === 1 && errorCount === 0) {
showToast('Image uploadée avec succès ! ✨', 'success');
} else {
showToast(`${successCount} uploadée(s)${errorCount ? `, ${errorCount} échec(s)` : ''}`, errorCount ? 'info' : 'success');
}
}
}
// ─── Drag & Drop ───
const dropZone = document.getElementById('drop-zone');
['dragenter', 'dragover'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) uploadFiles(files);
});
// File input change
document.getElementById('file-input').addEventListener('change', (e) => {
if (e.target.files.length > 0) uploadFiles(e.target.files);
});
// Global paste handler (Ctrl+V)
document.addEventListener('paste', (e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
imageFiles.push(item.getAsFile());
}
}
if (imageFiles.length > 0) {
e.preventDefault();
uploadFiles(imageFiles);
}
});
// ─── Load Images ───
async function loadImages(page = 1) {
currentPage = page;
const gallery = document.getElementById('gallery');
const title = document.getElementById('gallery-title');
const noRes = document.getElementById('no-results');
gallery.innerHTML = '<div class="spinner"></div>';
noRes.style.display = 'none';
title.textContent = currentQuery ? '🔍 Résultats de recherche' : '🖼️ Captures récentes';
try {
const endpoint = currentQuery
? `/api/images/search?q=${encodeURIComponent(currentQuery)}&page=${page}&per_page=24`
: `/api/images?page=${page}&per_page=24`;
const data = await api(endpoint);
renderGallery(data.images || data.results || [], data.total || 0, data.page || page);
} catch(e) {
gallery.innerHTML = `<div class="gallery empty"><div class="icon">⚠️</div><p>Impossible de charger les images : ${e.message}</p></div>`;
}
}
function renderGallery(images, total, page) {
const gallery = document.getElementById('gallery');
const pagination = document.getElementById('pagination');
const noRes = document.getElementById('no-results');
if (!images || images.length === 0) {
gallery.classList.add('empty');
if (currentQuery) {
gallery.innerHTML = '';
noRes.style.display = 'block';
} else {
gallery.innerHTML = `<div class="gallery empty"><div class="icon">📭</div><p>Aucune capture pour l'instant</p><p style="font-size:12px;margin-top:4px">Glissez-déposez des images ou utilisez le presse-papier</p></div>`;
}
pagination.innerHTML = '';
return;
}
gallery.classList.remove('empty');
noRes.style.display = 'none';
gallery.innerHTML = images.map(img => {
const tags = (img.tags || []).slice(0, 4).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
const desc = img.description || img.ocr_text || '';
const thumb = `/api/images/${img.id}/thumbnail`;
const dims = (img.width && img.height) ? `${img.width}×${img.height}` : '';
const cardId = `card-${img.id}`;
return `
<div class="card" id="${cardId}" onclick="openImage('${img.id}')">
<img class="card-thumb" src="${thumb}" alt="${escHtml(img.filename || '')}" loading="lazy" onerror="var t=document.getElementById('${cardId}').querySelector('.card-thumb');if(t){t.outerHTML='<div class=\\'card-thumb placeholder\\'>🖼️<br><small>Pas d\\'aperçu</small></div>'}">
<div class="card-body">
<div class="card-filename">${escHtml(img.filename || img.id)}</div>
<div class="card-meta">
<span>${fmtDate(img.created_at)}</span>
${dims ? `<span>${dims}</span>` : ''}
</div>
${tags ? `<div class="card-tags">${tags}</div>` : ''}
</div>
</div>`;
}).join('');
// Pagination
const totalPages = Math.ceil(total / 24);
if (totalPages > 1) {
let html = '';
html += `<button class="btn" ${page <= 1 ? 'disabled' : ''} onclick="loadImages(${page-1})">← Précédent</button>`;
html += `<span>Page ${page} / ${totalPages} (${total} images)</span>`;
html += `<button class="btn" ${page >= totalPages ? 'disabled' : ''} onclick="loadImages(${page+1})">Suivant →</button>`;
pagination.innerHTML = html;
} else {
pagination.innerHTML = `<span>${total} image${total > 1 ? 's' : ''}</span>`;
}
}
// ─── Modal ───
async function openImage(id) {
const overlay = document.getElementById('modal-overlay');
const modal = document.getElementById('modal');
overlay.classList.add('active');
modal.innerHTML = '<div class="spinner"></div>';
try {
const img = await api(`/api/images/${id}`);
const tags = (img.tags || []).map(t => `<span class="tag">${escHtml(t)}</span>`).join(' ');
modal.innerHTML = `
<button class="modal-close" onclick="closeModal()">✕</button>
<img src="/api/images/${id}/thumbnail?full=1" alt="${escHtml(img.filename)}" onerror="this.outerHTML='<div class=\\'modal-placeholder\\'>🖼️</div>'">
<div class="modal-details">
<h2>${escHtml(img.filename || img.id)}</h2>
${img.description ? `<p class="desc">${escHtml(img.description)}</p>` : ''}
${img.ocr_text ? `<p style="color:var(--fg-dim);font-size:12px;margin-bottom:8px;">📝 OCR détecté :</p><div class="ocr">${escHtml(img.ocr_text)}</div>` : ''}
<table>
<tr><td>ID</td><td style="font-family:monospace;font-size:11px">${img.id}</td></tr>
<tr><td>Dimensions</td><td>${img.width||'?'} × ${img.height||'?'}</td></tr>
<tr><td>Taille</td><td>${fmtBytes(img.size_bytes)}</td></tr>
<tr><td>Date</td><td>${fmtDate(img.created_at)}</td></tr>
<tr><td>Tags</td><td>${tags || '<span style="color:var(--fg-dim)">Aucun</span>'}</td></tr>
</table>
</div>`;
} catch(e) {
modal.innerHTML = `<button class="modal-close" onclick="closeModal()">✕</button><div style="padding:40px;text-align:center;color:var(--red)">Erreur : ${e.message}</div>`;
}
}
function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ─── Stats ───
async function loadStats() {
try {
const stats = await api('/api/stats');
document.getElementById('stat-images').textContent = (stats.total_images || 0).toLocaleString();
document.getElementById('stat-storage').textContent = fmtBytes(stats.total_storage_bytes);
document.getElementById('stat-tags').textContent = (stats.unique_tags || 0).toLocaleString();
} catch(e) { /* ignore */ }
}
// ─── WebSocket ───
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'ws-dot on';
document.getElementById('ws-label').textContent = 'Connecté · temps réel';
document.getElementById('stat-uptime').style.color = 'var(--green)';
};
ws.onclose = () => {
document.getElementById('ws-dot').className = 'ws-dot off';
document.getElementById('ws-label').textContent = 'Déconnecté';
setTimeout(connectWS, 3000);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'new_image') {
if (currentPage === 1 && !currentQuery) loadImages(1);
loadStats();
}
} catch(_) {}
};
}
// ─── Search ───
let searchTimer;
document.getElementById('search').addEventListener('input', (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
currentQuery = e.target.value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
if (currentQuery) loadImages(1);
}, 300);
});
document.getElementById('search').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchTimer);
currentQuery = e.target.value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
loadImages(1);
}
});
document.getElementById('search-btn').addEventListener('click', () => {
currentQuery = document.getElementById('search').value.trim();
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
loadImages(1);
});
document.getElementById('clear-btn').addEventListener('click', () => {
document.getElementById('search').value = '';
currentQuery = '';
document.getElementById('clear-btn').style.display = 'none';
loadImages(1);
});
// ─── Escape HTML ───
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Init ───
loadImages();
loadStats();
connectWS();
setInterval(loadStats, 30000);
// Keyboard shortcut: Ctrl+U to focus drop zone
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'u') {
e.preventDefault();
document.getElementById('drop-zone').scrollIntoView({behavior:'smooth'});
setTimeout(() => document.getElementById('file-input').click(), 300);
}
});
</script>
</body>
</html>