diff --git a/src/static/css/style.css b/src/static/css/style.css index 989918b..0d4a7cf 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -790,3 +790,44 @@ tr:hover td { .audit-diff .diff-new { color: #2ecc71; word-break: break-word; } .token-budget-bar.over-limit { background: repeating-linear-gradient(45deg, #c0392b, #c0392b 6px, #962d22 6px, #962d22 12px); } input[type="date"].filter-select { padding: 6px 10px; } + +/* === Toast-Notifications (Phase 3) === */ +.toast-container { + position: fixed; + top: 24px; + right: 24px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 380px; + pointer-events: none; +} +.toast { + background: #1e293b; + border: 1px solid #334155; + border-left-width: 4px; + border-radius: 8px; + padding: 12px 16px; + color: #e2e8f0; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + pointer-events: auto; + animation: toast-in 0.18s ease-out; +} +.toast.toast-out { + animation: toast-out 0.18s ease-in forwards; +} +.toast-info { border-left-color: #3b82f6; } +.toast-success { border-left-color: #10b981; } +.toast-warning { border-left-color: #f59e0b; } +.toast-error { border-left-color: #ef4444; } +@keyframes toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes toast-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(20px); } +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 47e4317..c0ab006 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -672,7 +672,7 @@

@@ -682,5 +682,6 @@ +
diff --git a/src/static/js/app.js b/src/static/js/app.js index 95481b9..1fd9a63 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -268,7 +268,7 @@ async function changeRole(userId, role) { try { await API.put(`/api/users/${userId}/role?role=${role}`); } catch (err) { - alert(err.message); + showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } @@ -278,7 +278,7 @@ async function toggleUser(userId, activate) { await API.put(`/api/users/${userId}/${activate ? "activate" : "deactivate"}`); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } @@ -287,7 +287,7 @@ async function toggleGlobeAccess(userId) { await API.put("/api/users/" + userId + "/globe-access"); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } @@ -297,7 +297,7 @@ async function toggleNetworkAccess(userId) { await API.put("/api/users/" + userId + "/network-access"); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } @@ -311,7 +311,7 @@ function confirmDeleteUser(userId, email) { await API.del(`/api/users/${userId}`); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } ); @@ -353,7 +353,7 @@ async function extendLicense(licId) { await API.put(`/api/licenses/${licId}/extend?days=${parseInt(days)}`); if (currentOrgId) loadOrgLicenses(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } @@ -366,7 +366,7 @@ function confirmRevokeLicense(licId) { await API.put(`/api/licenses/${licId}/revoke`); if (currentOrgId) loadOrgLicenses(currentOrgId); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } ); @@ -523,7 +523,7 @@ function setupForms() { loadOrgs(); loadDashboard(); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } }); @@ -541,7 +541,7 @@ function setupForms() { loadOrgs(); loadDashboard(); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } ); @@ -560,19 +560,56 @@ function closeModal(id) { // Confirm dialog let confirmCallback = null; +let confirmResolver = null; + function showConfirm(title, text, callback) { document.getElementById("confirmTitle").textContent = title; document.getElementById("confirmText").textContent = text; - confirmCallback = callback; + // Backward-compat: legacy Callback wird bei OK aufgerufen + confirmCallback = callback || null; openModal("modalConfirm"); + return new Promise((resolve) => { + if (confirmResolver) confirmResolver(false); // alten Resolver schliessen + confirmResolver = resolve; + }); +} + +function showToast(msg, type) { + type = type || "info"; + const c = document.getElementById("toastContainer"); + if (!c) { console.log("[toast]", type, msg); return; } + const el = document.createElement("div"); + el.className = "toast toast-" + type; + el.textContent = msg; + c.appendChild(el); + setTimeout(() => { + el.classList.add("toast-out"); + setTimeout(() => el.remove(), 220); + }, type === "error" ? 6000 : 3500); } document.addEventListener("DOMContentLoaded", () => { document.getElementById("confirmOkBtn").addEventListener("click", async () => { closeModal("modalConfirm"); - if (confirmCallback) await confirmCallback(); + const cb = confirmCallback; + const rs = confirmResolver; confirmCallback = null; + confirmResolver = null; + if (cb) { + try { await cb(); } catch (e) { showToast(e.message || String(e), "error"); } + } + if (rs) rs(true); }); + + // Cancel/Close -> resolver(false) + function _confirmCancel() { + const rs = confirmResolver; + confirmCallback = null; + confirmResolver = null; + if (rs) rs(false); + } + document.getElementById("confirmCancelBtn")?.addEventListener("click", _confirmCancel); + document.querySelector("#modalConfirm .modal-close")?.addEventListener("click", _confirmCancel); }); // --- Utilities --- diff --git a/src/static/js/source-health.js b/src/static/js/source-health.js index 558d94e..61b70b0 100644 --- a/src/static/js/source-health.js +++ b/src/static/js/source-health.js @@ -202,18 +202,19 @@ async function handleSuggestion(id, accept) { const suggestion = suggestionsCache.find((s) => s.id === id); if (!suggestion) return; - if (!confirm(`Vorschlag "${suggestion.title}" ${action}?`)) return; + const ok = await showConfirm("Vorschlag " + (action === "annehmen" ? "annehmen" : "ablehnen"), `Soll "${suggestion.title}" ${action}?`); + if (!ok) return; try { const result = await API.put("/api/sources/suggestions/" + id, { accept }); if (result.action) { - alert(`Ergebnis: ${result.action}`); + showToast("Ergebnis: " + result.action, "success"); } loadHealthData(); // Grundquellen-Liste auch aktualisieren if (typeof loadGlobalSources === "function") loadGlobalSources(); } catch (err) { - alert("Fehler: " + err.message); + showToast("Fehler: " + err.message, "error"); } } @@ -322,7 +323,8 @@ function formatDateTime(dateStr) { async function searchFix(btn) { const sourceId = btn.dataset.sourceId; const sourceName = btn.dataset.sourceName; - if (!confirm(`Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen?\n\nDas kann einige Minuten dauern.`)) return; + const ok = await showConfirm("Lösung suchen", `Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen? Das kann einige Minuten dauern.`); + if (!ok) return; btn.disabled = true; btn.textContent = "Sucht..."; @@ -337,10 +339,10 @@ async function searchFix(btn) { if (result.cost_usd) { msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`; } - alert(msg); + showToast(msg, "info"); loadHealthData(); } catch (err) { - alert("Fehler: " + err.message); + showToast("Fehler: " + err.message, "error"); } finally { btn.disabled = false; btn.textContent = "Lösung suchen"; diff --git a/src/static/js/sources.js b/src/static/js/sources.js index eb864a4..dc9d466 100644 --- a/src/static/js/sources.js +++ b/src/static/js/sources.js @@ -258,7 +258,7 @@ function confirmDeleteGlobalSource(id, name) { await API.del("/api/sources/global/" + id); loadGlobalSources(); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } ); @@ -320,7 +320,7 @@ function promoteSource(id, name) { await API.post("/api/sources/tenant/" + id + "/promote"); loadTenantSources(); } catch (err) { - alert(err.message); + showToast(err.message, "error"); } } ); @@ -399,7 +399,7 @@ async function addDiscoveredFeeds() { }); if (selected.length === 0) { - alert("Keine Feeds ausgewählt"); + showToast("Keine Feeds ausgewählt", "warning"); return; } @@ -411,9 +411,9 @@ async function addDiscoveredFeeds() { const result = await API.post("/api/sources/discover/add", selected); closeModal("modalDiscover"); loadGlobalSources(); - alert(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : "")); + showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success"); } catch (err) { - alert("Fehler: " + err.message); + showToast("Fehler: " + err.message, "error"); } finally { btn.disabled = false; btn.textContent = "Ausgewählte hinzufügen";