From ed0f8b2d571d3fcdc8dd95e5adebafb82b9ce675 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 19:03:38 +0200 Subject: [PATCH] feat(modal): warn before closing with unsaved changes --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/components/modal.js | 41 ++++++++++++++++++++++++++++++++++++-- public/locales/ar.json | 4 +++- public/locales/de.json | 4 +++- public/locales/el.json | 4 +++- public/locales/en.json | 4 +++- public/locales/es.json | 4 +++- public/locales/fr.json | 4 +++- public/locales/hi.json | 4 +++- public/locales/it.json | 4 +++- public/locales/ja.json | 4 +++- public/locales/pt.json | 4 +++- public/locales/ru.json | 4 +++- public/locales/sv.json | 4 +++- public/locales/tr.json | 4 +++- public/locales/uk.json | 4 +++- public/locales/zh.json | 4 +++- public/pages/budget.js | 4 ++-- public/pages/calendar.js | 4 ++-- public/pages/contacts.js | 4 ++-- public/pages/dashboard.js | 6 +++--- public/pages/meals.js | 8 ++++---- public/pages/notes.js | 4 ++-- public/pages/recipes.js | 6 +++--- public/pages/tasks.js | 4 ++-- 27 files changed, 112 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97434b6..085a139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.4] - 2026-04-26 + +### Added +- Modal: closing a modal (via Escape, swipe, overlay click, or X button) now shows a "Discard changes?" confirmation dialog when the form has been modified since it was opened; saving or deleting bypasses the prompt + ## [0.25.3] - 2026-04-26 ### Changed diff --git a/package-lock.json b/package-lock.json index df2cd7e..a021b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 19eeda1..1acf5f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/components/modal.js b/public/components/modal.js index 6b267b2..31f723f 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -16,6 +16,7 @@ import { t } from '/i18n.js'; let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; +let _initialFormSnapshot = null; // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus const OVERLAY_THEME_COLOR = '#1A1A1A'; @@ -98,6 +99,20 @@ function trapFocus(container) { } } +// -------------------------------------------------------- +// Dirty-Check Helpers +// -------------------------------------------------------- + +function serializeForm(container) { + const inputs = container.querySelectorAll('input, select, textarea'); + return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&'); +} + +function isFormDirty(container) { + if (!_initialFormSnapshot) return false; + return serializeForm(container) !== _initialFormSnapshot; +} + // -------------------------------------------------------- // Escape-Handler // -------------------------------------------------------- @@ -204,9 +219,10 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals // nicht die noch animierende alte Instanz zurückgibt – sonst landen alle // Event-Listener am falschen Element und Buttons reagieren nicht. + // force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich). if (activeOverlay) { activeOverlay.removeAttribute('id'); - closeModal(); + closeModal({ force: true }); } // Focus-Restore vorbereiten @@ -243,6 +259,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} const panel = activeOverlay.querySelector('.modal-panel'); trapFocus(panel); + // Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden) + _initialFormSnapshot = null; + setTimeout(() => { + if (activeOverlay) { + _initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay); + } + }, 150); + // Swipe-to-Close auf Mobile if (window.innerWidth < 768) { _wireSheetSwipe(panel); @@ -279,9 +303,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // closeModal // -------------------------------------------------------- -export function closeModal() { +export async function closeModal({ force = false } = {}) { if (!activeOverlay) return; + if (!force) { + const panel = activeOverlay.querySelector('.modal-panel'); + if (panel && isFormDirty(panel)) { + const confirmed = await confirmModal(t('modal.unsavedChanges'), { + danger: false, + confirmLabel: t('modal.discardChanges'), + }); + if (!confirmed) return; + } + } + + _initialFormSnapshot = null; + document.removeEventListener('keydown', onEscape); // Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal diff --git a/public/locales/ar.json b/public/locales/ar.json index f65615d..b178083 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "إغلاق", - "overlayLabel": "خلفية مربع الحوار" + "overlayLabel": "خلفية مربع الحوار", + "unsavedChanges": "تجاهل التغييرات؟", + "discardChanges": "تجاهل" }, "rrule": { "freqNone": "بدون تكرار", diff --git a/public/locales/de.json b/public/locales/de.json index 294f40a..72888a0 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -709,7 +709,9 @@ }, "modal": { "closeLabel": "Schließen", - "overlayLabel": "Modaler Dialog-Hintergrund" + "overlayLabel": "Modaler Dialog-Hintergrund", + "unsavedChanges": "Änderungen verwerfen?", + "discardChanges": "Verwerfen" }, "rrule": { "freqNone": "Keine Wiederholung", diff --git a/public/locales/el.json b/public/locales/el.json index bb5c421..8c5924c 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Κλείσιμο", - "overlayLabel": "Φόντο αναδυόμενου παραθύρου" + "overlayLabel": "Φόντο αναδυόμενου παραθύρου", + "unsavedChanges": "Απόρριψη αλλαγών;", + "discardChanges": "Απόρριψη" }, "rrule": { "freqNone": "Χωρίς επανάληψη", diff --git a/public/locales/en.json b/public/locales/en.json index 083a815..569a1ad 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Close", - "overlayLabel": "Modal dialog background" + "overlayLabel": "Modal dialog background", + "unsavedChanges": "Discard changes?", + "discardChanges": "Discard" }, "rrule": { "freqNone": "No recurrence", diff --git a/public/locales/es.json b/public/locales/es.json index 4720f89..6db084f 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Cerrar", - "overlayLabel": "Fondo del cuadro de diálogo modal" + "overlayLabel": "Fondo del cuadro de diálogo modal", + "unsavedChanges": "¿Descartar cambios?", + "discardChanges": "Descartar" }, "rrule": { "freqNone": "Sin repetición", diff --git a/public/locales/fr.json b/public/locales/fr.json index 3134b52..0fc627e 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Fermer", - "overlayLabel": "Arrière-plan de la boîte de dialogue modale" + "overlayLabel": "Arrière-plan de la boîte de dialogue modale", + "unsavedChanges": "Abandonner les modifications ?", + "discardChanges": "Abandonner" }, "rrule": { "freqNone": "Pas de répétition", diff --git a/public/locales/hi.json b/public/locales/hi.json index 0753297..2ab1479 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "बंद करें", - "overlayLabel": "मोडल डायलॉग पृष्ठभूमि" + "overlayLabel": "मोडल डायलॉग पृष्ठभूमि", + "unsavedChanges": "बदलाव छोड़ें?", + "discardChanges": "छोड़ें" }, "rrule": { "freqNone": "कोई दोहराव नहीं", diff --git a/public/locales/it.json b/public/locales/it.json index a6cb3fd..fe7fa3a 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Chiudi", - "overlayLabel": "Sfondo del dialogo modale" + "overlayLabel": "Sfondo del dialogo modale", + "unsavedChanges": "Annullare le modifiche?", + "discardChanges": "Annulla" }, "rrule": { "freqNone": "Nessuna ripetizione", diff --git a/public/locales/ja.json b/public/locales/ja.json index 953ecb1..31a91b1 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "閉じる", - "overlayLabel": "モーダルダイアログの背景" + "overlayLabel": "モーダルダイアログの背景", + "unsavedChanges": "変更を破棄しますか?", + "discardChanges": "破棄" }, "rrule": { "freqNone": "繰り返しなし", diff --git a/public/locales/pt.json b/public/locales/pt.json index 62d88ff..d2e766e 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Fechar", - "overlayLabel": "Fundo do diálogo modal" + "overlayLabel": "Fundo do diálogo modal", + "unsavedChanges": "Descartar alterações?", + "discardChanges": "Descartar" }, "rrule": { "freqNone": "Sem repetição", diff --git a/public/locales/ru.json b/public/locales/ru.json index c78b3c0..6aabd07 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Закрыть", - "overlayLabel": "Фон модального диалога" + "overlayLabel": "Фон модального диалога", + "unsavedChanges": "Отменить изменения?", + "discardChanges": "Отменить" }, "rrule": { "freqNone": "Без повтора", diff --git a/public/locales/sv.json b/public/locales/sv.json index c1082a4..0ced812 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Stäng", - "overlayLabel": "Bakgrund för modal dialog" + "overlayLabel": "Bakgrund för modal dialog", + "unsavedChanges": "Ignorera ändringar?", + "discardChanges": "Ignorera" }, "rrule": { "freqNone": "Ingen upprepning", diff --git a/public/locales/tr.json b/public/locales/tr.json index e5ee568..8aecf1e 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Kapat", - "overlayLabel": "Modal iletişim kutusu arka planı" + "overlayLabel": "Modal iletişim kutusu arka planı", + "unsavedChanges": "Değişiklikler iptal edilsin mi?", + "discardChanges": "İptal et" }, "rrule": { "freqNone": "Tekrar yok", diff --git a/public/locales/uk.json b/public/locales/uk.json index fe01e5e..3e2efb8 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Закрити", - "overlayLabel": "Фон модального вікна" + "overlayLabel": "Фон модального вікна", + "unsavedChanges": "Скасувати зміни?", + "discardChanges": "Скасувати" }, "rrule": { "freqNone": "Без повторення", diff --git a/public/locales/zh.json b/public/locales/zh.json index e603cca..87e8039 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "关闭", - "overlayLabel": "模态对话框背景" + "overlayLabel": "模态对话框背景", + "unsavedChanges": "放弃更改?", + "discardChanges": "放弃" }, "rrule": { "freqNone": "不重复", diff --git a/public/pages/budget.js b/public/pages/budget.js index bdc69cf..b6c46dc 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteEntry(entry.id); }); @@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) { const sumRes = await api.get(`/budget/summary?month=${state.month}`); state.summary = sumRes.data; - closeModal(); + closeModal({ force: true }); renderBody(); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 698c7ac..24ddb05 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -867,7 +867,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteEvent(event.id); }); @@ -1060,7 +1060,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) { } } - closeModal(); + closeModal({ force: true }); renderView(); window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); } catch (err) { diff --git a/public/pages/contacts.js b/public/pages/contacts.js index e4551a3..79f50b9 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -304,7 +304,7 @@ function openContactModal({ mode, contact = null }) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteContact(contact.id); }); @@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) { const idx = state.contacts.findIndex((c) => c.id === contact.id); if (idx !== -1) state.contacts[idx] = res.data; } - closeModal(); + closeModal({ force: true }); renderList(); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 5a706b9..68c42ff 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -637,7 +637,7 @@ function openCustomizeModal(currentConfig, onSave) { saveBtn.disabled = true; try { await api.put('/preferences', { dashboard_widgets: draft }); - closeModal(); + closeModal({ force: true }); onSave(draft); window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500); } catch { @@ -674,7 +674,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) { panel.querySelector('[data-action="done"]').addEventListener('click', async () => { try { await api.patch(`/tasks/${taskId}/status`, { status: 'done' }); - closeModal(); + closeModal({ force: true }); window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success'); rerender(); } catch (err) { @@ -682,7 +682,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) { } }); panel.querySelector('[data-action="edit"]').addEventListener('click', () => { - closeModal(); + closeModal({ force: true }); window.oikos.navigate(`/tasks?open=${taskId}`); }); }, diff --git a/public/pages/meals.js b/public/pages/meals.js index 0dbea21..28ec46a 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -707,7 +707,7 @@ function openMealModal(opts) { if (res.data.transferred > 0) { window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success'); await loadWeek(state.currentWeek); - closeModal(); + closeModal({ force: true }); renderWeekGrid(); } else { window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); @@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) { `; } -function closeModal() { - closeSharedModal(); +function closeModal({ force = false } = {}) { + closeSharedModal({ force }); state.modal = null; } @@ -894,7 +894,7 @@ async function saveModal(overlay) { await loadWeek(state.currentWeek); } - closeModal(); + closeModal({ force: true }); renderWeekGrid(); window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success'); } catch (err) { diff --git a/public/pages/notes.js b/public/pages/notes.js index f04dc89..c19e9ec 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -445,7 +445,7 @@ function openNoteModal({ mode, note = null }) { if (idx !== -1) state.notes[idx] = res.data; state.notes.sort((a, b) => b.pinned - a.pinned); } - closeModal(); + closeModal({ force: true }); renderGrid(); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { @@ -476,7 +476,7 @@ async function togglePin(id) { } async function deleteNote(id) { - closeModal(); + closeModal({ force: true }); const note = state.notes.find((n) => n.id === id); state.notes = state.notes.filter((n) => n.id !== id); renderGrid(); diff --git a/public/pages/recipes.js b/public/pages/recipes.js index 8ead862..04b2a8f 100644 --- a/public/pages/recipes.js +++ b/public/pages/recipes.js @@ -326,8 +326,8 @@ function openRecipeModal(mode, recipe = null) { }); } -function closeModal() { - closeSharedModal(); +function closeModal({ force = false } = {}) { + closeSharedModal({ force }); } async function saveRecipe(panel, mode, recipe) { @@ -361,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) { if (idx >= 0) state.recipes[idx] = res.data; } - closeModal(); + closeModal({ force: true }); renderRecipeList(); window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success'); } catch (err) { diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 660d121..bbd079f 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) { } btnSuccess(submitBtn, originalLabel); - setTimeout(() => closeModal(), 700); + setTimeout(() => closeModal({ force: true }), 700); await loadTasks(container); } catch (err) { errorEl.textContent = err.message; @@ -582,7 +582,7 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - closeModal(); + closeModal({ force: true }); const itemEl = container.querySelector(`[data-task-id="${id}"]`); if (itemEl) itemEl.style.display = 'none';