diff --git a/CHANGELOG.md b/CHANGELOG.md index 124fcb3..d1a70fb 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.8.1] - 2026-04-04 + +### Fixed +- Replace native `prompt()` dialogs with custom modals in shopping (create/rename list), tasks (add subtask), and meals (choose shopping list) - native prompts were unreliable on mobile/PWA, requiring multiple clicks to close (#12) + ## [0.8.0] - 2026-04-04 ### Added diff --git a/package.json b/package.json index 2466e5b..d860142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.8.0", + "version": "0.8.1", "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 5327314..1045064 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -276,6 +276,152 @@ export function closeModal() { _doClose(); } +// -------------------------------------------------------- +// promptModal - Ersatz für native prompt() +// -------------------------------------------------------- + +/** + * Öffnet ein Modal mit Textfeld als Ersatz für native prompt(). + * Gibt ein Promise zurück: string bei OK, null bei Cancel/Escape. + * + * @param {string} label - Beschriftung / Frage + * @param {string} [defaultValue=''] - Vorausgefüllter Wert + * @returns {Promise} + */ +export function promptModal(label, defaultValue = '') { + return new Promise((resolve) => { + let resolved = false; + + function finish(value) { + if (resolved) return; + resolved = true; + closeModal(); + resolve(value); + } + + openModal({ + title: label, + size: 'sm', + content: ` +
+
+ +
+ +
`, + onSave(panel) { + const form = panel.querySelector('#prompt-modal-form'); + const input = panel.querySelector('#prompt-modal-input'); + const cancel = panel.querySelector('#prompt-modal-cancel'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + finish(input.value.trim() || null); + }); + + cancel.addEventListener('click', () => finish(null)); + + // Escape soll null liefern (closeModal wird über onEscape bereits ausgelöst) + const escHandler = (e) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + finish(null); + } + }; + document.addEventListener('keydown', escHandler); + + // Overlay-Click soll null liefern + const overlay = panel.closest('.modal-overlay'); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(null); + }); + } + + // Input fokussieren und Text selektieren + setTimeout(() => { + input.focus(); + input.select(); + }, 50); + }, + }); + }); +} + +// -------------------------------------------------------- +// selectModal - Ersatz für native prompt() mit Auswahlliste +// -------------------------------------------------------- + +/** + * Öffnet ein Modal mit Select-Dropdown als Ersatz für native prompt() bei Listenauswahl. + * + * @param {string} label - Beschriftung / Frage + * @param {{ value: string|number, label: string }[]} options - Auswahloptionen + * @returns {Promise} + */ +export function selectModal(label, options) { + return new Promise((resolve) => { + let resolved = false; + + function finish(value) { + if (resolved) return; + resolved = true; + closeModal(); + resolve(value); + } + + const optionsHtml = options + .map((o) => ``) + .join(''); + + openModal({ + title: label, + size: 'sm', + content: ` +
+
+ +
+ +
`, + onSave(panel) { + const form = panel.querySelector('#select-modal-form'); + const select = panel.querySelector('#select-modal-input'); + const cancel = panel.querySelector('#select-modal-cancel'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + finish(select.value); + }); + + cancel.addEventListener('click', () => finish(null)); + + const escHandler = (e) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + finish(null); + } + }; + document.addEventListener('keydown', escHandler); + + const overlay = panel.closest('.modal-overlay'); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(null); + }); + } + }, + }); + }); +} + // -------------------------------------------------------- // Inline Blur-Validierung // -------------------------------------------------------- diff --git a/public/pages/meals.js b/public/pages/meals.js index 2600b90..7d487be 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 } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -695,11 +695,10 @@ async function transferMeal(mealId) { let listId = state.lists[0].id; if (state.lists.length > 1) { - const names = state.lists.map((l, i) => `${i + 1}. ${l.name}`).join('\n'); - const choice = prompt(`Auf welche Einkaufsliste?\n${names}\nNummer eingeben:`); - const n = parseInt(choice, 10); - if (!n || n < 1 || n > state.lists.length) return; - listId = state.lists[n - 1].id; + const options = state.lists.map((l) => ({ value: l.id, label: l.name })); + const choice = await selectModal(t('meals.transferToShoppingList'), options); + if (choice === null) return; + listId = Number(choice); } try { diff --git a/public/pages/shopping.js b/public/pages/shopping.js index bbef484..da88fad 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -8,6 +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'; // -------------------------------------------------------- // Konstanten @@ -571,10 +572,10 @@ function wireTabBar(container) { } if (target.dataset.action === 'new-list') { - const name = prompt(t('shopping.newListPrompt')); - if (!name?.trim()) return; + const name = await promptModal(t('shopping.newListPrompt')); + if (!name) return; try { - const data = await api.post('/shopping', { name: name.trim() }); + const data = await api.post('/shopping', { name }); state.lists.push({ ...data.data, item_total: 0, item_checked: 0 }); await switchList(data.data.id, container); } catch (err) { @@ -652,10 +653,10 @@ function wireListContentEvents(container) { // ---- Liste umbenennen ---- if (action === 'rename-list') { - const newName = prompt(t('shopping.renameListPrompt'), state.activeList?.name); - if (!newName?.trim() || newName.trim() === state.activeList?.name) return; + const newName = await promptModal(t('shopping.renameListPrompt'), state.activeList?.name ?? ''); + if (!newName || newName === state.activeList?.name) return; try { - const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() }); + const data = await api.put(`/shopping/${state.activeListId}`, { name: newName }); const idx = state.lists.findIndex((l) => l.id === state.activeListId); if (idx >= 0) state.lists[idx].name = data.data.name; state.activeList = data.data; diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 39fa71c..54427a3 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 } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -481,10 +481,10 @@ async function handleDeleteTask(id, container) { } async function handleAddSubtask(parentId, container) { - const title = prompt(t('tasks.subtaskPrompt')); - if (!title?.trim()) return; + const title = await promptModal(t('tasks.subtaskPrompt')); + if (!title) return; try { - await api.post('/tasks', { title: title.trim(), parent_task_id: parentId }); + await api.post('/tasks', { title, parent_task_id: parentId }); await loadTasks(container); } catch (err) { window.oikos.showToast(err.message, 'danger'); diff --git a/public/styles/layout.css b/public/styles/layout.css index 42c5c49..b000ccb 100644 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -690,6 +690,18 @@ gap: var(--space-4); } +.form-stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + .modal-panel__footer { display: flex; align-items: center;