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:
+150
-8
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user