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
632 lines
28 KiB
HTML
632 lines
28 KiB
HTML
<!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>
|