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 || '')}
${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)} `; } 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,