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:
2026-06-23 10:31:02 -04:00
parent 29b4390844
commit f25174f143
4 changed files with 349 additions and 34 deletions
Generated
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>,