feat: suppression images + gestion tags (ajout/suppression)

Backend:
- ImagoClient::delete_image() — DELETE /api/v1/images/{id}
- ImagoClient::update_tags() — PUT /api/v1/images/{id}/tags
- Route DELETE /api/images/{id}
- Route PUT /api/images/{id}/tags

Frontend:
- Bouton 🗑️ sur chaque carte (visible au hover)
- Boîte de confirmation avant suppression
- Tags cliquables avec × pour suppression
- Bouton + tag pour ajouter un tag (Enter pour valider)
- Bouton Supprimer + tags éditables dans la modale
- Suppression + modif tags aussi dans la modale

⚠️ Nécessite redéploiement Imago pour le endpoint PUT /tags
This commit is contained in:
2026-06-23 15:20:26 -04:00
parent 8256d4c362
commit 1ec0d4fbe8
3 changed files with 238 additions and 10 deletions
+150 -8
View File
@@ -200,8 +200,49 @@
.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; }
.card-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; align-items: center; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 12px; background: rgba(88,166,255,0.12); color: var(--accent); white-space: nowrap; cursor: default; }
.tag .tag-remove { cursor: pointer; margin-left: 3px; opacity: 0.5; font-size: 11px; }
.tag .tag-remove:hover { opacity: 1; color: var(--red); }
.tag.editing { background: rgba(88,166,255,0.25); border: 1px solid var(--accent); }
.tag-input {
font-size: 10px; padding: 2px 6px; border-radius: 12px;
background: var(--bg); border: 1px dashed var(--accent); color: var(--fg);
width: 80px; outline: none;
}
.tag-add-btn {
font-size: 11px; padding: 2px 8px; border-radius: 12px;
background: transparent; border: 1px dashed var(--border); color: var(--fg-dim);
cursor: pointer; white-space: nowrap;
}
.tag-add-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ─── Card Delete ─── */
.card-delete {
position: absolute; top: 6px; right: 6px; z-index: 5;
width: 28px; height: 28px; border-radius: 50%;
background: rgba(0,0,0,0.6); border: none; color: var(--fg-dim);
font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s, color 0.15s;
}
.card:hover .card-delete, .card:focus-within .card-delete { opacity: 1; }
.card-delete:hover { color: var(--red); background: rgba(248,81,73,0.15); }
.card { position: relative; }
/* ─── Confirm Dialog ─── */
.confirm-overlay {
position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
}
.confirm-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 24px; max-width: 380px; width: 90%;
box-shadow: var(--shadow); text-align: center;
}
.confirm-box p { margin-bottom: 16px; font-size: 14px; }
.confirm-box .confirm-actions { display: flex; gap: 10px; justify-content: center; }
.btn-danger { background: var(--red); color: #fff; border-color: var(--red); }
.btn-danger:hover { background: #da3633; border-color: #da3633; color: #fff; }
.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); }
@@ -476,6 +517,89 @@ function logout() {
showToast('Déconnecté', 'info', 2000);
}
// ─── Delete ───
function confirmDelete(id, filename) {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.id = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<p>🗑️ Supprimer <strong>${escHtml(filename)}</strong> ?</p>
<p style="font-size:12px;color:var(--fg-dim)">Cette action est irréversible.</p>
<div class="confirm-actions">
<button class="btn" onclick="document.getElementById('confirm-overlay').remove()">Annuler</button>
<button class="btn btn-danger" onclick="deleteImage(${id})">Supprimer</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
async function deleteImage(id) {
const overlay = document.getElementById('confirm-overlay');
if (overlay) overlay.remove();
try {
await api(`/api/images/${id}`, { method: 'DELETE' });
showToast('Image supprimée 🗑️', 'success');
loadImages(currentPage);
loadStats();
} catch(e) {
showToast(`Échec suppression : ${e.message}`, 'error');
}
}
// ─── Tags ───
async function removeTag(imgId, tag) {
try {
// Get current tags from the API
const img = await api(`/api/images/${imgId}`);
const tags = (img.tags || []).filter(t => t !== tag);
await api(`/api/images/${imgId}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags })
});
showToast(`Tag "${tag}" supprimé`, 'success', 2000);
loadImages(currentPage);
loadStats();
} catch(e) {
showToast(`Échec : ${e.message}`, 'error');
}
}
function addTagInput(event, imgId) {
event.stopPropagation();
const btn = event.target;
const input = document.createElement('input');
input.className = 'tag-input';
input.placeholder = 'nouveau tag…';
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const newTag = input.value.trim();
if (!newTag) { input.remove(); return; }
try {
const img = await api(`/api/images/${imgId}`);
const tags = [...(img.tags || []), newTag];
await api(`/api/images/${imgId}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags })
});
showToast(`Tag "${newTag}" ajouté`, 'success', 2000);
loadImages(currentPage);
loadStats();
} catch(e) {
showToast(`Échec : ${e.message}`, 'error');
}
input.remove();
}
if (e.key === 'Escape') input.remove();
});
input.addEventListener('blur', () => setTimeout(() => input.remove(), 150));
btn.parentNode.insertBefore(input, btn);
input.focus();
}
// ─── Upload ───
async function uploadFiles(files) {
if (!files || files.length === 0) return;
@@ -614,13 +738,19 @@ function renderGallery(images, total, page) {
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 tagHtml = (img.tags || []).slice(0, 4).map(t => {
const et = escHtml(t);
const safe = et.replace(/'/g,"\\'");
return `<span class="tag" onclick="event.stopPropagation();removeTag(${img.id},'${safe}')">${et}<span class="tag-remove">×</span></span>`;
}).join('');
const thumb = `/api/images/${img.id}/thumbnail`;
const dims = (img.width && img.height) ? `${img.width}×${img.height}` : '';
const cardId = `card-${img.id}`;
const safeFn = escHtml(img.filename || img.id).replace(/'/g,"\\'");
return `
<div class="card" id="${cardId}" onclick="openImage('${img.id}')">
<div class="card" id="${cardId}">
<button class="card-delete" onclick="event.stopPropagation();confirmDelete(${img.id},'${safeFn}')" title="Supprimer">🗑️</button>
<div 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>
@@ -628,7 +758,11 @@ function renderGallery(images, total, page) {
<span>${fmtDate(img.created_at)}</span>
${dims ? `<span>${dims}</span>` : ''}
</div>
${tags ? `<div class="card-tags">${tags}</div>` : ''}
<div class="card-tags">
${tagHtml}
<button class="tag-add-btn" onclick="event.stopPropagation();addTagInput(event,${img.id})">+ tag</button>
</div>
</div>
</div>
</div>`;
}).join('');
@@ -654,12 +788,20 @@ async function openImage(id) {
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(' ');
const tagsHtml = (img.tags || []).map(t => {
const et = escHtml(t);
const safe = et.replace(/'/g,"\\'");
return `<span class="tag" style="cursor:pointer" onclick="removeTag(${id},'${safe}');closeModal()">${et}<span class="tag-remove">×</span></span>`;
}).join(' ');
const safeFn = escHtml(img.filename || img.id).replace(/'/g,"\\'");
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>
<div style="margin-bottom:12px;display:flex;gap:8px">
<button class="btn btn-danger" style="font-size:11px;padding:4px 10px" onclick="confirmDelete(${id},'${safeFn}');closeModal()">🗑️ Supprimer</button>
</div>
${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>
@@ -667,7 +809,7 @@ async function openImage(id) {
<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>
<tr><td>Tags</td><td>${tagsHtml || '<span style="color:var(--fg-dim)">Aucun</span>'} <button class="tag-add-btn" onclick="addTagInput(event,${id})">+ tag</button></td></tr>
</table>
</div>`;
} catch(e) {
+23
View File
@@ -311,4 +311,27 @@ impl ImagoClient {
let data = response.bytes().await?.to_vec();
Ok((data, content_type))
}
/// Supprime une image (nécessite le scope images:delete)
pub async fn delete_image(&self, id: i64) -> Result<()> {
self.client
.delete(format!("{}/{}", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Met à jour les tags d'une image
pub async fn update_tags(&self, id: i64, tags: Vec<String>) -> Result<()> {
self.client
.put(format!("{}/{}/tags", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.json(&serde_json::json!({"tags": tags}))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
+65 -2
View File
@@ -1,7 +1,7 @@
//! Routes API REST du dashboard — proxy vers Imago v2
use axum::{
Router, routing::{get, post},
Router, routing::{get, post, put, delete},
extract::{Multipart, Path, Query, State},
http::HeaderMap,
response::IntoResponse,
@@ -97,7 +97,8 @@ pub fn api_routes(state: AppState) -> Router {
.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))
.route("/api/images/{id}", get(get_image).delete(delete_image))
.route("/api/images/{id}/tags", put(update_tags))
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
.route("/api/stats", get(stats))
.with_state(state)
@@ -447,6 +448,68 @@ async fn get_thumbnail(
}
}
async fn delete_image(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> impl IntoResponse {
let img_id: i64 = match id.parse() {
Ok(n) => n,
Err(_) => return error_response(400, "ID invalide"),
};
let client = match get_client(&state, &headers) {
Ok(c) => c,
Err(e) => return error_response(500, &format!("Erreur client : {}", e)),
};
match client.delete_image(img_id).await {
Ok(()) => (
axum::http::StatusCode::OK,
Json(serde_json::json!({"deleted": true, "id": img_id})),
),
Err(e) => {
let err_msg = format!("{:#}", e);
let status = if err_msg.contains("403") || err_msg.contains("401") { 403 } else { 500 };
error_response(status, &format!("Échec suppression : {}", err_msg))
}
}
}
/// Requête de mise à jour des tags
#[derive(Deserialize)]
pub struct TagsUpdate {
pub tags: Vec<String>,
}
async fn update_tags(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<TagsUpdate>,
) -> impl IntoResponse {
let img_id: i64 = match id.parse() {
Ok(n) => n,
Err(_) => return error_response(400, "ID invalide"),
};
let client = match get_client(&state, &headers) {
Ok(c) => c,
Err(e) => return error_response(500, &format!("Erreur client : {}", e)),
};
match client.update_tags(img_id, body.tags.clone()).await {
Ok(()) => (
axum::http::StatusCode::OK,
Json(serde_json::json!({"updated": true, "id": img_id, "tags": body.tags})),
),
Err(e) => {
let err_msg = format!("{:#}", e);
error_response(500, &format!("Échec mise à jour tags : {}", err_msg))
}
}
}
async fn stats(
State(state): State<Arc<AppState>>,
headers: HeaderMap,