From 99a2280c028361f860c6347841d459e2d2f9d170 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 06:52:35 +0200 Subject: [PATCH] chore: release v0.42.0 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 6 ++ package-lock.json | 4 +- package.json | 2 +- public/locales/de.json | 15 ++++ public/locales/en.json | 15 ++++ public/pages/settings.js | 47 +++++++++++- public/pages/tasks.js | 137 ++++++++++++++++++++++++++++++++++- public/router.js | 84 +++++++++++++++------ public/styles/tasks.css | 35 +++++++++ server/routes/preferences.js | 34 ++++++++- 10 files changed, 348 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 547dc0a..0881d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.42.0] - 2026-05-04 + +### Added +- **Module toggles** (Settings → General, admin-only): individual modules (Tasks, Calendar, Shopping, Meals, Recipes, Birthdays, Notes, Contacts, Budget, Documents) can be disabled to hide them from the navigation. Data is preserved and reappears when the module is re-enabled. Dashboard and Settings remain essential and cannot be disabled. +- **Bulk actions for tasks** (List view only): select multiple tasks via checkboxes and apply batch operations (mark done, mark open, archive, delete). Bulk select toggle appears in the toolbar; selected count and action bar appear when tasks are checked. Kanban view remains single-task oriented. + ## [0.41.0] - 2026-05-01 ### Added diff --git a/package-lock.json b/package-lock.json index 29af6bb..95c579e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.41.0", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 62efd59..ce72508 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.41.0", + "version": "0.42.0", "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/locales/de.json b/public/locales/de.json index f16b1b1..d8e9df9 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -210,6 +210,17 @@ "statusArchived": "Archiviert", "archiveButton": "Aufgabe archivieren", "archivedToast": "Aufgabe archiviert.", + "bulkSelect": "Mehrfachauswahl", + "selectTask": "Aufgabe auswählen", + "bulkSelectedCount": "{{count}} ausgewählt", + "bulkMarkDone": "Erledigt", + "bulkMarkOpen": "Offen", + "bulkArchive": "Archivieren", + "bulkDelete": "Löschen", + "bulkDeleteConfirm": "{{count}} Aufgaben unwiderruflich löschen?", + "bulkStatusChanged": "Status geändert.", + "bulkArchived": "Aufgaben archiviert.", + "bulkDeleted": "Aufgaben gelöscht.", "kanbanArchived": "Archiviert", "reminderNeedsDueDate": "Lege ein Fälligkeitsdatum fest, um Aufgabenerinnerungen zu aktivieren." }, @@ -755,6 +766,10 @@ "tabsAriaLabel": "Einstellungsbereiche", "sectionDesign": "Design", "sectionAppName": "Anwendungsname", + "sectionModules": "Module", + "modulesTitle": "Aktive Module", + "modulesHint": "Deaktivierte Module verschwinden aus der Navigation. Daten bleiben erhalten und können nach Reaktivierung wieder genutzt werden.", + "modulesSaved": "Modul-Sichtbarkeit gespeichert.", "sectionShopping": "Einkauf", "shoppingCategoriesLabel": "Einkaufskategorien", "shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.", diff --git a/public/locales/en.json b/public/locales/en.json index ffff38e..94cb6bb 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -184,6 +184,17 @@ "createdToast": "Task created.", "deletedToast": "Task deleted.", "archivedToast": "Task archived.", + "bulkSelect": "Bulk select", + "selectTask": "Select task", + "bulkSelectedCount": "{{count}} selected", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkArchive": "Archive", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkStatusChanged": "Status changed.", + "bulkArchived": "Tasks archived.", + "bulkDeleted": "Tasks deleted.", "loadError": "Task could not be loaded.", "subtaskPrompt": "Subtask:", "kanbanOpen": "Open", @@ -749,6 +760,10 @@ "tabsAriaLabel": "Settings sections", "sectionDesign": "Appearance", "sectionAppName": "Application name", + "sectionModules": "Modules", + "modulesTitle": "Active modules", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", "sectionShopping": "Shopping", "shoppingCategoriesLabel": "Shopping Categories", "shoppingCategoriesHint": "Add, rename, delete or reorder categories.", diff --git a/public/pages/settings.js b/public/pages/settings.js index c1c72c9..68f1e28 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -199,7 +199,7 @@ export async function render(container, { user }) { let users = []; let googleStatus = { configured: false, connected: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null }; - let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME }; + let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [] }; let categories = []; let icsSubscriptions = []; let apiTokens = []; @@ -359,6 +359,35 @@ export async function render(container, { user }) { + + ${user?.role === 'admin' ? ` +
+

${t('settings.sectionModules')}

+
+

${t('settings.modulesTitle')}

+

${t('settings.modulesHint')}

+
+ ${[ + ['tasks', 'nav.tasks'], + ['calendar', 'nav.calendar'], + ['meals', 'nav.meals'], + ['recipes', 'nav.recipes'], + ['shopping', 'nav.shopping'], + ['birthdays', 'nav.birthdays'], + ['notes', 'nav.notes'], + ['contacts', 'nav.contacts'], + ['budget', 'nav.budget'], + ['documents', 'nav.documents'], + ].map(([slug, labelKey]) => ` + + `).join('')} +
+
+
+ ` : ''} @@ -823,6 +852,22 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok }); } + // Modul-Toggles (admin-only) + const moduleToggles = container.querySelector('#module-toggles'); + if (moduleToggles) { + moduleToggles.addEventListener('change', async () => { + const disabled = [...moduleToggles.querySelectorAll('input:not(:checked)')].map((cb) => cb.value); + try { + const res = await api.put('/preferences', { disabled_modules: disabled }); + const saved = res?.data?.disabled_modules ?? disabled; + window.oikos?.setDisabledModules?.(saved); + window.oikos?.showToast(t('settings.modulesSaved'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + } + }); + } + // Meal-Type-Toggles const mealToggles = container.querySelector('#meal-type-toggles'); if (mealToggles) { diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 4e17e1b..062a62b 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -159,7 +159,7 @@ function renderSwipeRow(task, innerHtml) { } function renderTaskCard(task, opts = {}) { - const { expandedSubtasks = false } = opts; + const { expandedSubtasks = false, showCheckbox = false, isChecked = false } = opts; const isDone = task.status === 'done'; const progress = task.subtask_total > 0 ? Math.round((task.subtask_done / task.subtask_total) * 100) @@ -181,6 +181,10 @@ function renderTaskCard(task, opts = {}) { return `
+ ${showCheckbox ? ` + + ` : ''}
- ${sorted.map((t) => renderSwipeRow(t, renderTaskCard(t))).join('')} + ${sorted.map((t) => renderSwipeRow(t, renderTaskCard(t, { + showCheckbox: state.bulkSelectMode, + isChecked: state.selectedTaskIds.has(t.id), + }))).join('')}
`; }).join(''); } @@ -414,6 +421,8 @@ let state = { expandedTasks: new Set(), dragTaskId: null, filterPanelOpen: false, + bulkSelectMode: false, + selectedTaskIds: new Set(), }; // -------------------------------------------------------- @@ -1504,11 +1513,21 @@ function wireViewToggle(container) { ); const groupToggle = container.querySelector('#group-mode-toggle'); if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none'; + const bulkSelectBtn = container.querySelector('#btn-bulk-select'); + if (bulkSelectBtn) { + bulkSelectBtn.style.display = state.viewMode === 'list' ? '' : 'none'; + if (state.viewMode === 'kanban') { + state.bulkSelectMode = false; + state.selectedTaskIds.clear(); + bulkSelectBtn.classList.remove('btn--active'); + } + } // Skeleton-Flash: einen Frame Render-Feedback geben, dann Ansicht aufbauen const listEl = container.querySelector('#task-list'); if (listEl) listEl.style.opacity = '0.4'; requestAnimationFrame(() => { renderTaskList(container); + updateBulkActionsBar(container); const el = container.querySelector('#task-list'); if (el) { el.style.transition = 'opacity 0.15s'; el.style.opacity = ''; } }); @@ -1538,6 +1557,92 @@ function wireNewTaskBtn(container) { container.querySelector('#fab-new-task')?.addEventListener('click', handler); } +function updateBulkActionsBar(container) { + const bar = container.querySelector('#bulk-actions-bar'); + const count = container.querySelector('#bulk-count'); + if (!bar) return; + + const selected = state.selectedTaskIds.size; + if (selected === 0) { + bar.hidden = true; + } else { + bar.hidden = false; + count.textContent = t('tasks.bulkSelectedCount', { count: selected }); + } +} + +function wireBulkSelect(container) { + const toggleBtn = container.querySelector('#btn-bulk-select'); + if (!toggleBtn) return; + + toggleBtn.addEventListener('click', () => { + state.bulkSelectMode = !state.bulkSelectMode; + if (!state.bulkSelectMode) { + state.selectedTaskIds.clear(); + } + toggleBtn.classList.toggle('btn--active', state.bulkSelectMode); + loadTasks(container); + }); +} + +function wireBulkCheckboxes(container) { + const listEl = container.querySelector('#task-list'); + if (!listEl) return; + + listEl.addEventListener('change', (e) => { + const checkbox = e.target.closest('.task-bulk-checkbox'); + if (!checkbox) return; + + const taskId = Number(checkbox.dataset.taskId); + if (checkbox.checked) { + state.selectedTaskIds.add(taskId); + } else { + state.selectedTaskIds.delete(taskId); + } + updateBulkActionsBar(container); + }); +} + +function wireBulkActions(container) { + const bar = container.querySelector('#bulk-actions-bar'); + if (!bar) return; + + bar.addEventListener('click', async (e) => { + const btn = e.target.closest('button[id^="bulk-"]'); + if (!btn) return; + + const taskIds = [...state.selectedTaskIds]; + if (taskIds.length === 0) return; + + const action = btn.id; + let confirm = true; + if (action === 'bulk-delete') { + confirm = window.confirm(t('tasks.bulkDeleteConfirm', { count: taskIds.length })); + } + if (!confirm) return; + + try { + if (action === 'bulk-mark-done' || action === 'bulk-mark-open') { + const status = btn.dataset.status; + await Promise.all(taskIds.map(id => api.patch(`/tasks/${id}/status`, { status }))); + window.oikos.showToast(t('tasks.bulkStatusChanged'), 'success'); + } else if (action === 'bulk-archive') { + await Promise.all(taskIds.map(id => api.patch(`/tasks/${id}/status`, { status: 'archived' }))); + window.oikos.showToast(t('tasks.bulkArchived'), 'success'); + } else if (action === 'bulk-delete') { + await Promise.all(taskIds.map(id => api.delete(`/tasks/${id}`))); + window.oikos.showToast(t('tasks.bulkDeleted'), 'success'); + } + + state.selectedTaskIds.clear(); + updateBulkActionsBar(container); + await loadTasks(container); + } catch (err) { + window.oikos.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + } + }); +} + function wireTaskList(container) { const listEl = container.querySelector('#task-list'); if (!listEl) return; @@ -1638,6 +1743,10 @@ export async function render(container, { user }) { + @@ -1647,6 +1756,27 @@ export async function render(container, { user }) {
+
${[1,2,3].map(() => ` @@ -1691,6 +1821,9 @@ export async function render(container, { user }) { wireGroupToggle(container); wireNewTaskBtn(container); wireTaskList(container); + wireBulkSelect(container); + wireBulkCheckboxes(container); + wireBulkActions(container); renderFilters(container); renderTaskList(container); diff --git a/public/router.js b/public/router.js index 0105c59..55149fd 100644 --- a/public/router.js +++ b/public/router.js @@ -121,6 +121,7 @@ let currentUser = null; let currentPath = null; let isNavigating = false; let _preferencesLoaded = false; +let _disabledModules = new Set(); // Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert. // Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt. let _pendingLoginRedirect = false; @@ -239,7 +240,15 @@ async function navigate(path, userOrPushState = true, pushState = true) { const basePath = path.split('?')[0]; currentPath = basePath; - const route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/'); + let route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/'); + + // Modul-Guard: deaktivierte Module leiten auf Dashboard um. + if (route.module && _disabledModules.has(route.module) && route.path !== '/') { + currentPath = null; + isNavigating = false; + navigate('/'); + return; + } // Auth-Guard if (route.requiresAuth && !currentUser) { @@ -308,6 +317,9 @@ async function syncPreferencesOnce() { setAppName(res.data.app_name); updateBranding(); } + if (Array.isArray(res?.data?.disabled_modules)) { + _disabledModules = new Set(res.data.disabled_modules); + } } catch { // Non-critical. The settings page can refresh this later. } @@ -981,22 +993,32 @@ function renderSearchResults(container, data, onClose) { } function navItems() { - return [ - { path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' }, - { path: '/calendar', label: t('nav.calendar'), icon: 'calendar' }, + const all = [ + { path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard', module: 'dashboard' }, + { path: '/calendar', label: t('nav.calendar'), icon: 'calendar', module: 'calendar' }, // More-Sheet Items: - { path: '/tasks', label: t('nav.tasks'), icon: 'check-square' }, - { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' }, - { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, - { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, - { path: '/budget', label: t('nav.budget'), icon: 'wallet' }, - { path: '/documents', label: t('nav.documents'), icon: 'folder-lock' }, - { path: '/settings', label: t('nav.settings'), icon: 'settings' }, + { path: '/tasks', label: t('nav.tasks'), icon: 'check-square', module: 'tasks' }, + { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake', module: 'birthdays' }, + { path: '/notes', label: t('nav.notes'), icon: 'sticky-note', module: 'notes' }, + { path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' }, + { path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' }, + { path: '/documents', label: t('nav.documents'), icon: 'folder-lock', module: 'documents' }, + { path: '/settings', label: t('nav.settings'), icon: 'settings', module: 'settings' }, // Kitchen-Gruppe: via Küche-Nav-Button (Bottom-Nav + Sidebar) + kitchen-tabs-bar erreichbar - { path: '/meals', label: t('nav.meals'), icon: 'utensils', kitchenGroup: true }, - { path: '/recipes', label: t('nav.recipes'), icon: 'book-text', kitchenGroup: true }, - { path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', kitchenGroup: true }, + { path: '/meals', label: t('nav.meals'), icon: 'utensils', module: 'meals', kitchenGroup: true }, + { path: '/recipes', label: t('nav.recipes'), icon: 'book-text', module: 'recipes', kitchenGroup: true }, + { path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', module: 'shopping', kitchenGroup: true }, ]; + return all.filter((item) => !_disabledModules.has(item.module)); +} + +function isModuleDisabled(moduleName) { + return _disabledModules.has(moduleName); +} + +function setDisabledModules(modules) { + _disabledModules = new Set(Array.isArray(modules) ? modules : []); + rebuildNavigation(); } function navItemEl({ path, label, icon }) { @@ -1276,8 +1298,9 @@ window.addEventListener('auth:expired', () => { } }); -// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden -window.addEventListener('locale-changed', () => { +// Navigation komplett neu rendern (z.B. nach Sprach- oder Modul-Toggle-Änderung). +// Behält Bottom-Bar-Buttons (Kitchen, More) und More-Sheet-Handle/Suche bei. +function rebuildNavigation({ updateLabels = true } = {}) { const skipLink = document.querySelector('.sr-only[href="#main-content"]'); const navSidebar = document.querySelector('.nav-sidebar'); const navSidebarItems = document.querySelector('.nav-sidebar__items'); @@ -1286,10 +1309,14 @@ window.addEventListener('locale-changed', () => { const moreSheet = document.querySelector('#more-sheet'); const moreBtnLabel = document.querySelector('#more-btn .nav-item__label'); - if (skipLink) skipLink.textContent = t('common.skipToContent'); - if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); - if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); - if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more'); + if (updateLabels) { + if (skipLink) skipLink.textContent = t('common.skipToContent'); + if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); + if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); + if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more'); + } + + const kitchenVisible = ['meals', 'recipes', 'shopping'].some((m) => !_disabledModules.has(m)); if (navSidebarItems) { const sidebarEls = []; @@ -1297,7 +1324,7 @@ window.addEventListener('locale-changed', () => { .filter((item) => !item.kitchenGroup) .forEach((item) => { sidebarEls.push(navItemEl(item)); - if (item.path === '/calendar') sidebarEls.push(sidebarKitchenEl()); + if (item.path === '/calendar' && kitchenVisible) sidebarEls.push(sidebarKitchenEl()); }); navSidebarItems.replaceChildren(...sidebarEls); if (window.lucide) window.lucide.createIcons({ el: navSidebarItems }); @@ -1305,9 +1332,13 @@ window.addEventListener('locale-changed', () => { if (bottomItems) { const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn'); const moreBtn = bottomItems.querySelector('#more-btn'); - if (kitchenBtnEl) kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen'); + if (kitchenBtnEl) { + kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen'); + kitchenBtnEl.hidden = !kitchenVisible; + } const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl); - bottomItems.replaceChildren(...newItems, kitchenBtnEl, moreBtn); + const tail = [kitchenBtnEl, moreBtn].filter(Boolean); + bottomItems.replaceChildren(...newItems, ...tail); } if (moreSheet) { const handle = moreSheet.querySelector('.more-sheet__handle'); @@ -1330,7 +1361,10 @@ window.addEventListener('locale-changed', () => { updateNav(currentPath); updateBranding(currentPath || '/'); -}); +} + +// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden +window.addEventListener('locale-changed', () => rebuildNavigation()); window.addEventListener('app-name-changed', () => { updateBranding(currentPath || '/'); @@ -1422,6 +1456,8 @@ window.oikos = { showToast, friendlyError, setThemeColor, + setDisabledModules, + isModuleDisabled, applyTheme: (value) => { localStorage.setItem('oikos-theme', value); if (value === 'dark') { diff --git a/public/styles/tasks.css b/public/styles/tasks.css index bf7f3e5..ac47e7f 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -776,3 +776,38 @@ opacity: 0.5; } +/* -------------------------------------------------------- + Bulk selection + -------------------------------------------------------- */ + +.task-bulk-checkbox { + width: 20px; + height: 20px; + margin-right: var(--space-2); + cursor: pointer; + flex-shrink: 0; +} + +.bulk-actions-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + margin-bottom: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.bulk-actions-bar__count { + font-weight: 600; + color: var(--color-text-primary); +} + +.bulk-actions-bar__actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + diff --git a/server/routes/preferences.js b/server/routes/preferences.js index 3f132d2..3ae7ba2 100644 --- a/server/routes/preferences.js +++ b/server/routes/preferences.js @@ -28,6 +28,13 @@ const DEFAULT_TIME_FORMAT = '24h'; const VALID_WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes']; const VALID_WIDGET_SIZES = ['1x1', '1x2', '1x3', '1x4', '2x1', '2x2', '2x3', '2x4', '3x1', '3x2', '3x3', '3x4', '4x1', '4x2', '4x3', '4x4']; +// Modul-Slugs, die per Settings deaktiviert werden können. +// Dashboard und Settings sind absichtlich nicht enthalten — sie sind essentiell. +const TOGGLEABLE_MODULES = [ + 'tasks', 'calendar', 'meals', 'recipes', 'shopping', + 'birthdays', 'notes', 'contacts', 'budget', 'documents', +]; + function defaultWidgetSize(id) { if (['tasks', 'calendar'].includes(id)) return '2x2'; if (['weather', 'shopping', 'notes'].includes(id)) return '2x1'; @@ -76,6 +83,17 @@ function parseWidgetConfig(raw) { } } +function parseDisabledModules(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m)); + } catch { + return []; + } +} + function normalizeWidgetConfig(input) { const valid = Array.isArray(input) ? input @@ -115,6 +133,7 @@ router.get('/', (req, res) => { const timeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT; const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); + const disabledModules = parseDisabledModules(cfgGet('disabled_modules')); res.json({ data: { @@ -124,6 +143,7 @@ router.get('/', (req, res) => { time_format: timeFormat, app_name: appName, dashboard_widgets: dashboardWidgets, + disabled_modules: disabledModules, }, }); } catch (err) { @@ -141,7 +161,7 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { - const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets } = req.body; + const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules } = req.body; if (visible_meal_types !== undefined) { if (!Array.isArray(visible_meal_types)) { @@ -190,6 +210,16 @@ router.put('/', (req, res) => { cfgSet('dashboard_widgets', JSON.stringify(normalized)); } + if (disabled_modules !== undefined) { + if (!Array.isArray(disabled_modules)) { + return res.status(400).json({ error: 'disabled_modules muss ein Array sein', code: 400 }); + } + const filtered = disabled_modules + .filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m)); + const unique = [...new Set(filtered)]; + cfgSet('disabled_modules', JSON.stringify(unique)); + } + const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES; const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; @@ -197,6 +227,7 @@ router.put('/', (req, res) => { const savedTimeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT; const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); + const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules')); res.json({ data: { @@ -206,6 +237,7 @@ router.put('/', (req, res) => { time_format: savedTimeFormat, app_name: savedAppName, dashboard_widgets: savedWidgets, + disabled_modules: savedDisabledModules, }, }); } catch (err) {