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
This commit is contained in:
Generated
+33
@@ -169,6 +169,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -535,6 +536,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1621,6 +1631,23 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
@@ -2531,6 +2558,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ thiserror = "2"
|
||||
open = "5"
|
||||
|
||||
# Web server (axum + tower)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
|
||||
# Frontend embedding
|
||||
|
||||
+253
-31
@@ -2,7 +2,7 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Imago Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
@@ -28,6 +28,7 @@
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
@@ -41,13 +42,13 @@
|
||||
}
|
||||
.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 { 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; }
|
||||
.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: 200px; }
|
||||
.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);
|
||||
@@ -59,11 +60,12 @@
|
||||
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;
|
||||
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; }
|
||||
@@ -80,8 +82,52 @@
|
||||
.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); }
|
||||
.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;
|
||||
@@ -95,6 +141,7 @@
|
||||
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;
|
||||
@@ -106,15 +153,13 @@
|
||||
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-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; }
|
||||
.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 ─── */
|
||||
@@ -122,12 +167,17 @@
|
||||
.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;
|
||||
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; }
|
||||
@@ -137,19 +187,57 @@
|
||||
.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; }
|
||||
.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); } }
|
||||
|
||||
/* ─── Empty search ─── */
|
||||
.no-results { text-align: center; padding: 48px 24px; color: var(--fg-dim); }
|
||||
/* ─── 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: 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; }
|
||||
@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>
|
||||
@@ -163,12 +251,16 @@
|
||||
<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">
|
||||
<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 -->
|
||||
@@ -179,6 +271,14 @@
|
||||
<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>
|
||||
@@ -194,6 +294,9 @@
|
||||
<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;
|
||||
@@ -201,9 +304,14 @@ 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}`);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -219,6 +327,114 @@ function fmtDate(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;
|
||||
@@ -251,7 +467,7 @@ function renderGallery(images, total, page) {
|
||||
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>`;
|
||||
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;
|
||||
@@ -303,7 +519,7 @@ async function openImage(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 style=\\'aspect-ratio:16/10;background:#161b22;display:flex;align-items:center;justify-content:center;color:#8b949e;border-radius:8px;margin-bottom:16px\\'><span style=\\'font-size:48px\\'>🖼️</span></div>'">
|
||||
<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>` : ''}
|
||||
@@ -351,7 +567,6 @@ function connectWS() {
|
||||
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();
|
||||
}
|
||||
@@ -366,9 +581,7 @@ document.getElementById('search').addEventListener('input', (e) => {
|
||||
searchTimer = setTimeout(() => {
|
||||
currentQuery = e.target.value.trim();
|
||||
document.getElementById('clear-btn').style.display = currentQuery ? 'inline-flex' : 'none';
|
||||
if (currentQuery) {
|
||||
loadImages(1);
|
||||
}
|
||||
if (currentQuery) loadImages(1);
|
||||
}, 300);
|
||||
});
|
||||
document.getElementById('search').addEventListener('keydown', (e) => {
|
||||
@@ -403,7 +616,16 @@ function escHtml(s) {
|
||||
loadImages();
|
||||
loadStats();
|
||||
connectWS();
|
||||
setInterval(loadStats, 30000); // Refresh stats every 30s
|
||||
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>
|
||||
|
||||
+62
-2
@@ -1,8 +1,8 @@
|
||||
//! Routes API REST du dashboard — proxy vers Imago v2
|
||||
|
||||
use axum::{
|
||||
Router, routing::get,
|
||||
extract::{Path, Query, State},
|
||||
Router, routing::{get, post},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
@@ -55,6 +55,7 @@ pub fn api_routes(state: AppState) -> Router {
|
||||
let state = Arc::new(state);
|
||||
Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/upload", post(upload_image))
|
||||
.route("/api/images", get(list_images))
|
||||
.route("/api/images/search", get(search_images))
|
||||
.route("/api/images/{id}", get(get_image))
|
||||
@@ -69,6 +70,65 @@ async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
/// Upload d'image : reçoit un fichier et le transmet à l'API Imago
|
||||
async fn upload_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut data: Vec<u8> = Vec::new();
|
||||
let mut filename = String::from("upload.png");
|
||||
let mut mime_type = String::from("image/png");
|
||||
|
||||
while let Ok(Some(mut field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "file" {
|
||||
if let Some(fn_) = field.file_name() {
|
||||
filename = fn_.to_string();
|
||||
}
|
||||
if let Some(mt) = field.content_type() {
|
||||
mime_type = mt.to_string();
|
||||
}
|
||||
while let Ok(Some(chunk)) = field.chunk().await {
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Aucun fichier reçu"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Validation MIME basique côté dashboard
|
||||
if !mime_type.starts_with("image/") {
|
||||
return (
|
||||
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
Json(serde_json::json!({"error": format!("Type MIME non supporté : {}", mime_type)})),
|
||||
);
|
||||
}
|
||||
|
||||
match state.imago.upload_image(data, &filename, None).await {
|
||||
Ok(response) => (
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"id": response.id,
|
||||
"uuid": response.uuid,
|
||||
"filename": response.original_name,
|
||||
"status": response.status,
|
||||
})),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!("Upload vers Imago échoué : {:#}", e);
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({"error": format!("Échec de l'upload : {:#}", e)})),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_images(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
|
||||
Reference in New Issue
Block a user