diff --git a/src/frontend/index.html b/src/frontend/index.html
index 8f50a05..fac73a5 100644
--- a/src/frontend/index.html
+++ b/src/frontend/index.html
@@ -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 = `
+
+
🗑️ Supprimer ${escHtml(filename)} ?
+
Cette action est irréversible.
+
+
+
+
+
`;
+ 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 => `${escHtml(t)}`).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 `${et}×`;
+ }).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 `
-
+
+
+
${escHtml(img.filename || img.id)}
@@ -628,7 +758,11 @@ function renderGallery(images, total, page) {
${fmtDate(img.created_at)}
${dims ? `
${dims}` : ''}
- ${tags ? `
${tags}
` : ''}
+
+ ${tagHtml}
+
+
+
`;
}).join('');
@@ -654,12 +788,20 @@ async function openImage(id) {
modal.innerHTML = '';
try {
const img = await api(`/api/images/${id}`);
- const tags = (img.tags || []).map(t => `${escHtml(t)}`).join(' ');
+ const tagsHtml = (img.tags || []).map(t => {
+ const et = escHtml(t);
+ const safe = et.replace(/'/g,"\\'");
+ return `${et}×`;
+ }).join(' ');
+ const safeFn = escHtml(img.filename || img.id).replace(/'/g,"\\'");
modal.innerHTML = `
${escHtml(img.filename || img.id)}
+
+
+
${img.description ? `
${escHtml(img.description)}
` : ''}
${img.ocr_text ? `
📝 OCR détecté :
${escHtml(img.ocr_text)}
` : ''}
@@ -667,7 +809,7 @@ async function openImage(id) {
| Dimensions | ${img.width||'?'} × ${img.height||'?'} |
| Taille | ${fmtBytes(img.size_bytes)} |
| Date | ${fmtDate(img.created_at)} |
- | Tags | ${tags || 'Aucun'} |
+ | Tags | ${tagsHtml || 'Aucun'} |
`;
} catch(e) {
diff --git a/src/imago_client.rs b/src/imago_client.rs
index 85f1fa0..69384fa 100644
--- a/src/imago_client.rs
+++ b/src/imago_client.rs
@@ -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) -> 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(())
+ }
}
diff --git a/src/server/routes.rs b/src/server/routes.rs
index 20b91dd..904c6d3 100644
--- a/src/server/routes.rs
+++ b/src/server/routes.rs
@@ -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>,
+ headers: HeaderMap,
+ Path(id): Path,
+) -> 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,
+}
+
+async fn update_tags(
+ State(state): State>,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(body): Json,
+) -> 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>,
headers: HeaderMap,