diff --git a/public/components/modal.js b/public/components/modal.js index 52cc234..b37b5ef 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -422,6 +422,64 @@ export function selectModal(label, options) { }); } +// -------------------------------------------------------- +// confirmModal - Ersatz für native confirm() +// -------------------------------------------------------- + +/** + * Zeigt ein Bestätigungs-Modal als Ersatz für native confirm(). + * Gibt ein Promise zurück: true bei OK, false bei Cancel/Escape/Overlay-Klick. + * + * @param {string} message - Frage / Meldung im Titel + * @param {Object} [opts] + * @param {string} [opts.confirmLabel] - Text des Bestätigungs-Buttons (default: t('common.confirm')) + * @param {boolean} [opts.danger=false] - Roten Danger-Button statt Primary verwenden + * @returns {Promise} + */ +export function confirmModal(message, { confirmLabel, danger = false } = {}) { + return new Promise((resolve) => { + let resolved = false; + + function finish(value) { + if (resolved) return; + resolved = true; + closeModal(); + resolve(value); + } + + openModal({ + title: message, + size: 'sm', + content: ` + `, + onSave(panel) { + panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true)); + panel.querySelector('#confirm-modal-cancel')?.addEventListener('click', () => finish(false)); + + const escHandler = (e) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + finish(false); + } + }; + document.addEventListener('keydown', escHandler); + + const overlay = panel.closest('.modal-overlay'); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(false); + }); + } + }, + }); + }); +} + // -------------------------------------------------------- // Inline Blur-Validierung // -------------------------------------------------------- @@ -447,18 +505,28 @@ export function wireBlurValidation(formContainer) { /** * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms). + * Respektiert prefers-reduced-motion: zeigt nur Farb-Feedback ohne Icon-Wechsel. * @param {HTMLButtonElement} btn * @param {string} [originalLabel] */ export function btnSuccess(btn, originalLabel) { const label = originalLabel ?? btn.textContent; btn.classList.add('btn--success'); - btn.innerHTML = ` - - `; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!reducedMotion) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2.5'); + svg.setAttribute('aria-hidden', 'true'); + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + poly.setAttribute('points', '20 6 9 17 4 12'); + svg.appendChild(poly); + btn.replaceChildren(svg); + } setTimeout(() => { btn.classList.remove('btn--success'); btn.textContent = label; @@ -483,9 +551,15 @@ export function btnLoading(btn) { /** * Zeigt Fehler-Feedback auf einem Button (Shake-Animation). + * Respektiert prefers-reduced-motion: kein visuelles Schütteln, nur Farb-Feedback. * @param {HTMLButtonElement} btn */ export function btnError(btn) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + btn.classList.add('btn--error-static'); + setTimeout(() => btn.classList.remove('btn--error-static'), 700); + return; + } btn.classList.remove('btn--shaking'); void btn.offsetWidth; // Reflow für Animation-Restart btn.classList.add('btn--shaking'); diff --git a/public/locales/de.json b/public/locales/de.json index 53516ed..4d895c0 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -26,7 +26,9 @@ "nameRequired": "Name ist erforderlich", "contentRequired": "Inhalt ist erforderlich", "all": "Alle", - "unknownError": "Unbekannter Fehler" + "unknownError": "Unbekannter Fehler", + "confirm": "Bestätigen", + "undo": "Rückgängig" }, "nav": { @@ -155,6 +157,7 @@ "renameListPrompt": "Neuer Listen-Name:", "deleteListConfirm": "Liste \"{{name}}\" und alle Artikel löschen?", "deletedListToast": "Liste gelöscht.", + "itemDeletedToast": "\"{{name}}\" entfernt.", "itemsRemovedToast": "{{count}} Artikel entfernt.", "clearChecked": "Abgehakt löschen ({{count}})", "itemNamePlaceholder": "Artikel hinzufügen…", diff --git a/public/locales/en.json b/public/locales/en.json index ccad78f..71891f8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -26,7 +26,9 @@ "nameRequired": "Name is required", "contentRequired": "Content is required", "all": "All", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "confirm": "Confirm", + "undo": "Undo" }, "nav": { @@ -155,6 +157,7 @@ "renameListPrompt": "New list name:", "deleteListConfirm": "Delete list \"{{name}}\" and all items?", "deletedListToast": "List deleted.", + "itemDeletedToast": "\"{{name}}\" removed.", "itemsRemovedToast": "{{count}} items removed.", "clearChecked": "Remove checked ({{count}})", "itemNamePlaceholder": "Add item…", diff --git a/public/locales/it.json b/public/locales/it.json index 9d35c10..fdff221 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -26,7 +26,9 @@ "nameRequired": "Il nome è obbligatorio", "contentRequired": "Il contenuto è obbligatorio", "all": "Tutto", - "unknownError": "Errore sconosciuto" + "unknownError": "Errore sconosciuto", + "confirm": "Conferma", + "undo": "Annulla" }, "nav": { @@ -155,6 +157,7 @@ "renameListPrompt": "Nuovo nome lista:", "deleteListConfirm": "Eliminare la lista \"{{name}}\" e tutti gli articoli?", "deletedListToast": "Lista eliminata.", + "itemDeletedToast": "\"{{name}}\" rimosso.", "itemsRemovedToast": "{{count}} articoli rimossi.", "clearChecked": "Rimuovi selezionati ({{count}})", "itemNamePlaceholder": "Aggiungi articolo…", diff --git a/public/locales/sv.json b/public/locales/sv.json index dc74d38..3b364c2 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -26,7 +26,9 @@ "nameRequired": "Namn krävs", "contentRequired": "Innehåll krävs", "all": "Alla", - "unknownError": "Okänt fel" + "unknownError": "Okänt fel", + "confirm": "Bekräfta", + "undo": "Ångra" }, "nav": { @@ -155,6 +157,7 @@ "renameListPrompt": "Nytt listnamn:", "deleteListConfirm": "Ta bort listan \"{{name}}\" och alla objekt?", "deletedListToast": "Lista raderad.", + "itemDeletedToast": "\"{{name}}\" borttaget.", "itemsRemovedToast": "{{count}} objekt har tagits bort.", "clearChecked": "Ta bort markerad ({{count}})", "itemNamePlaceholder": "Lägg till objekt...", diff --git a/public/pages/budget.js b/public/pages/budget.js index a4059a8..f9bc995 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -6,7 +6,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -421,7 +421,6 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { - if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return; closeModal(); await deleteEntry(entry.id); }); @@ -474,7 +473,7 @@ function openBudgetModal({ mode, entry = null }) { // -------------------------------------------------------- async function deleteEntry(id) { - if (!confirm(t('budget.deleteConfirm'))) return; + if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/budget/${id}`); state.entries = state.entries.filter((e) => e.id !== id); diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 8429227..3b7519c 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -6,7 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; -import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatTime } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -701,7 +701,7 @@ function showEventPopup(ev, anchor) { }); popup.querySelector('#popup-delete').addEventListener('click', async () => { - if (!confirm(t('calendar.deleteConfirm', { title: ev.title }))) return; + if (!await confirmModal(t('calendar.deleteConfirm', { title: ev.title }), { danger: true, confirmLabel: t('common.delete') })) return; popup.remove(); await deleteEvent(ev.id); }); @@ -759,7 +759,7 @@ function openEventModal({ mode, event = null, date = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - if (!confirm(t('calendar.deleteConfirm', { title: event.title }))) return; + if (!await confirmModal(t('calendar.deleteConfirm', { title: event.title }), { danger: true, confirmLabel: t('common.delete') })) return; closeModal(); await deleteEvent(event.id); }); diff --git a/public/pages/contacts.js b/public/pages/contacts.js index f15831b..4c78bde 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -304,7 +304,6 @@ function openContactModal({ mode, contact = null }) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { - if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return; closeModal(); await deleteContact(contact.id); }); @@ -351,7 +350,7 @@ function openContactModal({ mode, contact = null }) { } async function deleteContact(id) { - if (!confirm(t('contacts.deleteConfirm'))) return; + if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/contacts/${id}`); state.contacts = state.contacts.filter((c) => c.id !== id); diff --git a/public/pages/meals.js b/public/pages/meals.js index bd53007..13dfde9 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal, confirmModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -681,7 +681,7 @@ async function saveModal(overlay) { // -------------------------------------------------------- async function deleteMeal(mealId) { - if (!confirm(t('meals.deleteMeal') + '?')) return; + if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/meals/${mealId}`); state.meals = state.meals.filter((m) => m.id !== mealId); diff --git a/public/pages/notes.js b/public/pages/notes.js index e830bf8..5fe3c37 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, btnError, confirmModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -476,7 +476,7 @@ async function togglePin(id) { } async function deleteNote(id) { - if (!confirm(t('notes.deleteConfirm'))) return; + if (!await confirmModal(t('notes.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/notes/${id}`); state.notes = state.notes.filter((n) => n.id !== id); diff --git a/public/pages/settings.js b/public/pages/settings.js index a1745df..c06723b 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -5,6 +5,7 @@ */ import { api, auth } from '/api.js'; +import { confirmModal } from '/components/modal.js'; import { t, formatDate, formatTime } from '/i18n.js'; import { esc } from '/utils/html.js'; import '/components/oikos-locale-picker.js'; @@ -423,7 +424,7 @@ function bindEvents(container, user) { const googleDisconnectBtn = container.querySelector('#google-disconnect-btn'); if (googleDisconnectBtn) { googleDisconnectBtn.addEventListener('click', async () => { - if (!confirm(t('settings.googleDisconnectConfirm'))) return; + if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return; try { await api.delete('/calendar/google/disconnect'); window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default'); @@ -456,7 +457,7 @@ function bindEvents(container, user) { const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn'); if (appleDisconnectBtn) { appleDisconnectBtn.addEventListener('click', async () => { - if (!confirm(t('settings.appleDisconnectConfirm'))) return; + if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return; try { await api.delete('/calendar/apple/disconnect'); window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default'); @@ -571,7 +572,7 @@ function bindDeleteButtons(container, user) { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.deleteUser, 10); const name = btn.dataset.name; - if (!confirm(t('settings.deleteMemberConfirm', { name }))) return; + if (!await confirmModal(t('settings.deleteMemberConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return; try { await auth.deleteUser(id); btn.closest('.settings-member').remove(); diff --git a/public/pages/shopping.js b/public/pages/shopping.js index 581532c..009d122 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -8,7 +8,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; -import { promptModal } from '/components/modal.js'; +import { promptModal, confirmModal } from '/components/modal.js'; // -------------------------------------------------------- // Konstanten @@ -677,35 +677,97 @@ function wireListContentEvents(container) { } } - // ---- Artikel löschen ---- + // ---- Artikel löschen (mit Undo, 4s Fenster) ---- if (action === 'delete-item') { - const id = Number(target.dataset.id); - const item = state.items.find((i) => i.id === id); - try { - await api.delete(`/shopping/items/${id}`); - state.items = state.items.filter((i) => i.id !== id); - updateItemsList(container); - updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0); - renderTabs(container); - } catch (err) { - window.oikos.showToast(err.message, 'danger'); - } + const id = Number(target.dataset.id); + const item = state.items.find((i) => i.id === id); + const snapshot = item ? { ...item } : null; + + // Optimistisch entfernen + state.items = state.items.filter((i) => i.id !== id); + updateItemsList(container); + updateListCounter(state.activeListId, -1, snapshot?.is_checked ? -1 : 0); + renderTabs(container); + + let undone = false; + window.oikos.showToast( + t('shopping.itemDeletedToast', { name: snapshot?.name ?? '' }), + 'default', + 4000, + () => { + // Undo: Artikel wiederherstellen + undone = true; + if (snapshot) { + state.items.push(snapshot); + state.items.sort((a, b) => a.id - b.id); + updateItemsList(container); + updateListCounter(state.activeListId, 1, snapshot.is_checked ? 1 : 0); + renderTabs(container); + } + }, + ); + + // Verzögert löschen — nur wenn kein Undo + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/shopping/items/${id}`); + } catch (err) { + // Rollback: Artikel war bereits aus UI entfernt, Fehler anzeigen + if (snapshot) { + state.items.push(snapshot); + state.items.sort((a, b) => a.id - b.id); + updateItemsList(container); + updateListCounter(state.activeListId, 1, snapshot.is_checked ? 1 : 0); + renderTabs(container); + } + window.oikos.showToast(err.message, 'danger'); + } + }, 4100); } - // ---- Abgehakte löschen ---- + // ---- Abgehakte löschen (mit Undo, 4s Fenster) ---- if (action === 'clear-checked') { - const count = state.items.filter((i) => i.is_checked).length; + const checked = state.items.filter((i) => i.is_checked); + const count = checked.length; if (!count) return; - try { - await api.delete(`/shopping/${state.activeListId}/items/checked`); - state.items = state.items.filter((i) => !i.is_checked); - updateItemsList(container); - updateListCounter(state.activeListId, -count, -count); - renderTabs(container); - window.oikos.showToast(t('shopping.itemsRemovedToast', { count })); - } catch (err) { - window.oikos.showToast(err.message, 'danger'); - } + + const snapshot = checked.map((i) => ({ ...i })); + + // Optimistisch entfernen + state.items = state.items.filter((i) => !i.is_checked); + updateItemsList(container); + updateListCounter(state.activeListId, -count, -count); + renderTabs(container); + + let undone = false; + window.oikos.showToast( + t('shopping.itemsRemovedToast', { count }), + 'default', + 4000, + () => { + undone = true; + snapshot.forEach((item) => state.items.push(item)); + state.items.sort((a, b) => a.id - b.id); + updateItemsList(container); + updateListCounter(state.activeListId, count, count); + renderTabs(container); + }, + ); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/shopping/${state.activeListId}/items/checked`); + } catch (err) { + snapshot.forEach((item) => state.items.push(item)); + state.items.sort((a, b) => a.id - b.id); + updateItemsList(container); + updateListCounter(state.activeListId, count, count); + renderTabs(container); + window.oikos.showToast(err.message, 'danger'); + } + }, 4100); } // ---- Liste umbenennen ---- @@ -727,7 +789,7 @@ function wireListContentEvents(container) { // ---- Liste löschen ---- if (action === 'delete-list') { - if (!confirm(t('shopping.deleteListConfirm', { name: state.activeList?.name }))) return; + if (!await confirmModal(t('shopping.deleteListConfirm', { name: state.activeList?.name }), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/shopping/${state.activeListId}`); state.lists = state.lists.filter((l) => l.id !== state.activeListId); diff --git a/public/pages/tasks.js b/public/pages/tasks.js index b73f5f5..53a1356 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -6,7 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; -import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal, confirmModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -471,7 +471,7 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - if (!confirm(t('tasks.deleteConfirm'))) return; + if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/tasks/${id}`); closeModal(); diff --git a/public/router.js b/public/router.js index 1b0b079..154ce35 100644 --- a/public/router.js +++ b/public/router.js @@ -397,10 +397,14 @@ const TOAST_ICONS = { warning: '', }; -function showToast(message, type = 'default', duration = 3000) { +function showToast(message, type = 'default', duration = 3000, onUndo = null) { const container = document.getElementById('toast-container'); if (!container) return; + // Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht + const existing = container.querySelectorAll('.toast'); + if (existing.length >= 3) existing[0].remove(); + const toast = document.createElement('div'); toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`; toast.setAttribute('role', 'alert'); @@ -412,8 +416,20 @@ function showToast(message, type = 'default', duration = 3000) { toast.innerHTML = icon; // eslint-disable-line no-unsanitized/property -- static SVG only toast.appendChild(span); + if (typeof onUndo === 'function') { + const undoBtn = document.createElement('button'); + undoBtn.className = 'toast__undo'; + undoBtn.textContent = t('common.undo'); + undoBtn.addEventListener('click', () => { + clearTimeout(dismissTimer); + toast.remove(); + onUndo(); + }); + toast.appendChild(undoBtn); + } + container.appendChild(toast); - setTimeout(() => { + const dismissTimer = setTimeout(() => { toast.classList.add('toast--out'); toast.addEventListener('animationend', () => toast.remove(), { once: true }); }, duration); diff --git a/public/styles/layout.css b/public/styles/layout.css index 0c37f10..263441e 100644 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1366,6 +1366,25 @@ height: 16px; } +.toast__undo { + margin-left: auto; + flex-shrink: 0; + padding: var(--space-1) var(--space-2); + background: transparent; + border: 1px solid currentColor; + border-radius: var(--radius-sm); + color: inherit; + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + cursor: pointer; + white-space: nowrap; + opacity: 0.85; +} + +.toast__undo:hover { + opacity: 1; +} + .toast--success { background-color: var(--color-success); color: var(--color-text-on-accent); } .toast--danger { background-color: var(--color-danger); color: var(--color-text-on-accent); } .toast--warning { background-color: var(--color-warning); color: var(--color-text-on-accent); } @@ -1491,6 +1510,13 @@ animation: btn-shake 0.3s ease; } +/* prefers-reduced-motion: kein Schütteln, nur Farb-Feedback */ +.btn--error-static { + background-color: var(--color-danger) !important; + color: var(--color-text-on-accent) !important; + pointer-events: none; +} + .btn--success { background-color: var(--color-success) !important; color: var(--color-text-on-accent) !important;