feat: Phase 9 — Dashboard web embarqué
- Frontend vanilla JS (18.9KB) : dark theme, gallery grid, search, stats, WebSocket - rust-embed : frontend servi depuis le binaire (zéro dépendance externe) - API REST fonctionnelle : /api/health, /api/images, /api/search, /api/images/:id, /api/stats - WebSocket hub (dashmap + broadcast) : notifications temps réel - ImagoClient: méthode get_raw() pour proxy thumbnails, Serialize sur ImageInfo - MIME guess pour servir CSS/JS/images avec bons headers - Fallback SPA : routes inconnues → index.html - Dépendances : tokio-stream, futures-util, dashmap, chrono - Testé : health OK, stats JSON, dashboard 18.9KB HTML, WS upgrade 400 (HTTP) 0 errors, 0 warnings (cargo check + build --release)
This commit is contained in:
+373
-13
@@ -5,34 +5,394 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Imago Dashboard</title>
|
||||
<style>
|
||||
/* Phase 9: Dashboard Svelte — ici juste un placeholder */
|
||||
: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', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
/* ─── 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; }
|
||||
.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; }
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search-bar { display: flex; gap: 8px; flex: 1; min-width: 200px; }
|
||||
.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;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ─── 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); }
|
||||
|
||||
/* ─── Gallery ─── */
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: var(--fg-dim); }
|
||||
.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-thumb {
|
||||
width: 100%; aspect-ratio: 16/10; object-fit: cover;
|
||||
display: block; background: #0d1117;
|
||||
}
|
||||
.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; }
|
||||
.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: 12px; max-width: 720px; width: 100%; max-height: 85vh;
|
||||
overflow-y: auto; 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-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: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; margin: 20px auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Empty search ─── */
|
||||
.no-results { text-align: center; padding: 48px 24px; color: var(--fg-dim); }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 640px) {
|
||||
.header { padding: 10px 12px; gap: 8px; }
|
||||
.header .logo { font-size: 16px; }
|
||||
.main { padding: 12px; }
|
||||
.gallery { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
}
|
||||
h1 { color: var(--accent); margin-bottom: 1rem; }
|
||||
p { color: #8b949e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="placeholder">
|
||||
<h1>🖼️ Imago Dashboard</h1>
|
||||
<p>Dashboard web en construction — Phase 9</p>
|
||||
|
||||
<!-- ─── 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">
|
||||
<button class="btn" id="search-btn">🔍</button>
|
||||
<button class="btn" id="clear-btn" style="display:none">✕</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script>
|
||||
// ─── State ───
|
||||
let currentPage = 1;
|
||||
let currentQuery = '';
|
||||
let ws = null;
|
||||
|
||||
// ─── API Helpers ───
|
||||
async function api(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
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' });
|
||||
}
|
||||
|
||||
// ─── 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">Les screenshots apparaîtront ici automatiquement</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}` : '';
|
||||
return `
|
||||
<div class="card" onclick="openImage('${img.id}')">
|
||||
<img class="card-thumb" src="${thumb}" alt="${escHtml(img.filename || '')}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22220%22 height=%22138%22><rect fill=%22%23161b22%22 width=%22220%22 height=%22138%22/><text x=%2250%25%22 y=%2250%25%22 fill=%22%238b949e%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2212%22>Pas d'aperçu</text></svg>'">
|
||||
<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.style.display='none'">
|
||||
<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') {
|
||||
// Option: append à la gallery sans recharger toute la page
|
||||
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); // Refresh stats every 30s
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user