chore: release v0.42.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 06:52:35 +02:00
parent 3b02cb1aee
commit 99a2280c02
10 changed files with 348 additions and 31 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.41.0] - 2026-05-01
### Added ### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.41.0", "version": "0.42.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.41.0", "version": "0.42.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "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.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+15
View File
@@ -210,6 +210,17 @@
"statusArchived": "Archiviert", "statusArchived": "Archiviert",
"archiveButton": "Aufgabe archivieren", "archiveButton": "Aufgabe archivieren",
"archivedToast": "Aufgabe archiviert.", "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", "kanbanArchived": "Archiviert",
"reminderNeedsDueDate": "Lege ein Fälligkeitsdatum fest, um Aufgabenerinnerungen zu aktivieren." "reminderNeedsDueDate": "Lege ein Fälligkeitsdatum fest, um Aufgabenerinnerungen zu aktivieren."
}, },
@@ -755,6 +766,10 @@
"tabsAriaLabel": "Einstellungsbereiche", "tabsAriaLabel": "Einstellungsbereiche",
"sectionDesign": "Design", "sectionDesign": "Design",
"sectionAppName": "Anwendungsname", "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", "sectionShopping": "Einkauf",
"shoppingCategoriesLabel": "Einkaufskategorien", "shoppingCategoriesLabel": "Einkaufskategorien",
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.", "shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
+15
View File
@@ -184,6 +184,17 @@
"createdToast": "Task created.", "createdToast": "Task created.",
"deletedToast": "Task deleted.", "deletedToast": "Task deleted.",
"archivedToast": "Task archived.", "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.", "loadError": "Task could not be loaded.",
"subtaskPrompt": "Subtask:", "subtaskPrompt": "Subtask:",
"kanbanOpen": "Open", "kanbanOpen": "Open",
@@ -749,6 +760,10 @@
"tabsAriaLabel": "Settings sections", "tabsAriaLabel": "Settings sections",
"sectionDesign": "Appearance", "sectionDesign": "Appearance",
"sectionAppName": "Application name", "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", "sectionShopping": "Shopping",
"shoppingCategoriesLabel": "Shopping Categories", "shoppingCategoriesLabel": "Shopping Categories",
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.", "shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
+46 -1
View File
@@ -199,7 +199,7 @@ export async function render(container, { user }) {
let users = []; let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null }; let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: 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 categories = [];
let icsSubscriptions = []; let icsSubscriptions = [];
let apiTokens = []; let apiTokens = [];
@@ -359,6 +359,35 @@ export async function render(container, { user }) {
<oikos-locale-picker></oikos-locale-picker> <oikos-locale-picker></oikos-locale-picker>
</div> </div>
</section> </section>
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionModules')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.modulesTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.modulesHint')}</p>
<div class="meal-type-toggles" id="module-toggles">
${[
['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]) => `
<label class="toggle-row">
<input type="checkbox" value="${slug}" ${prefs.disabled_modules?.includes(slug) ? '' : 'checked'}>
<span>${t(labelKey)}</span>
</label>
`).join('')}
</div>
</div>
</section>
` : ''}
</div> </div>
<!-- Panel: Mahlzeiten --> <!-- Panel: Mahlzeiten -->
@@ -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 // Meal-Type-Toggles
const mealToggles = container.querySelector('#meal-type-toggles'); const mealToggles = container.querySelector('#meal-type-toggles');
if (mealToggles) { if (mealToggles) {
+135 -2
View File
@@ -159,7 +159,7 @@ function renderSwipeRow(task, innerHtml) {
} }
function renderTaskCard(task, opts = {}) { function renderTaskCard(task, opts = {}) {
const { expandedSubtasks = false } = opts; const { expandedSubtasks = false, showCheckbox = false, isChecked = false } = opts;
const isDone = task.status === 'done'; const isDone = task.status === 'done';
const progress = task.subtask_total > 0 const progress = task.subtask_total > 0
? Math.round((task.subtask_done / task.subtask_total) * 100) ? Math.round((task.subtask_done / task.subtask_total) * 100)
@@ -181,6 +181,10 @@ function renderTaskCard(task, opts = {}) {
return ` return `
<div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}"> <div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}">
<div class="task-card__main"> <div class="task-card__main">
${showCheckbox ? `
<input type="checkbox" class="task-bulk-checkbox" data-task-id="${task.id}"
${isChecked ? 'checked' : ''} aria-label="${t('tasks.selectTask')}">
` : ''}
<button class="task-status-btn task-status-btn--${task.status}" <button class="task-status-btn task-status-btn--${task.status}"
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}" data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
aria-label="${isDone ? t('tasks.markOpen', { title: esc(task.title) }) : t('tasks.markDone', { title: esc(task.title) })}"> aria-label="${isDone ? t('tasks.markOpen', { title: esc(task.title) }) : t('tasks.markDone', { title: esc(task.title) })}">
@@ -286,7 +290,10 @@ function renderTaskGroups(tasks, groupMode) {
<span class="task-group__title">${catLabelsMap[name] ?? name}</span> <span class="task-group__title">${catLabelsMap[name] ?? name}</span>
<span class="task-group__count">${groupTasks.length}</span> <span class="task-group__count">${groupTasks.length}</span>
</div> </div>
${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('')}
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -414,6 +421,8 @@ let state = {
expandedTasks: new Set(), expandedTasks: new Set(),
dragTaskId: null, dragTaskId: null,
filterPanelOpen: false, filterPanelOpen: false,
bulkSelectMode: false,
selectedTaskIds: new Set(),
}; };
// -------------------------------------------------------- // --------------------------------------------------------
@@ -1504,11 +1513,21 @@ function wireViewToggle(container) {
); );
const groupToggle = container.querySelector('#group-mode-toggle'); const groupToggle = container.querySelector('#group-mode-toggle');
if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none'; 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 // Skeleton-Flash: einen Frame Render-Feedback geben, dann Ansicht aufbauen
const listEl = container.querySelector('#task-list'); const listEl = container.querySelector('#task-list');
if (listEl) listEl.style.opacity = '0.4'; if (listEl) listEl.style.opacity = '0.4';
requestAnimationFrame(() => { requestAnimationFrame(() => {
renderTaskList(container); renderTaskList(container);
updateBulkActionsBar(container);
const el = container.querySelector('#task-list'); const el = container.querySelector('#task-list');
if (el) { el.style.transition = 'opacity 0.15s'; el.style.opacity = ''; } 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); 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) { function wireTaskList(container) {
const listEl = container.querySelector('#task-list'); const listEl = container.querySelector('#task-list');
if (!listEl) return; if (!listEl) return;
@@ -1638,6 +1743,10 @@ export async function render(container, { user }) {
<i data-lucide="columns" class="icon-md" aria-hidden="true"></i> <i data-lucide="columns" class="icon-md" aria-hidden="true"></i>
</button> </button>
</div> </div>
<button class="btn btn--ghost btn--icon" id="btn-bulk-select" ${isKanban ? 'style="display:none"' : ''}
title="${t('tasks.bulkSelect')}" aria-label="${t('tasks.bulkSelect')}">
<i data-lucide="list-checks" class="icon-lg" aria-hidden="true"></i>
</button>
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)"> <button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
<i data-lucide="plus" class="icon-lg" aria-hidden="true"></i> ${t('tasks.newTask')} <i data-lucide="plus" class="icon-lg" aria-hidden="true"></i> ${t('tasks.newTask')}
</button> </button>
@@ -1647,6 +1756,27 @@ export async function render(container, { user }) {
<div class="tasks-body"> <div class="tasks-body">
<div class="tasks-filters" id="filter-bar"></div> <div class="tasks-filters" id="filter-bar"></div>
<div class="filter-panel" id="filter-panel" hidden></div> <div class="filter-panel" id="filter-panel" hidden></div>
<div class="bulk-actions-bar" id="bulk-actions-bar" hidden>
<span class="bulk-actions-bar__count" id="bulk-count"></span>
<div class="bulk-actions-bar__actions">
<button class="btn btn--secondary btn--sm" id="bulk-mark-done" data-status="done">
<i data-lucide="check" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkMarkDone')}
</button>
<button class="btn btn--secondary btn--sm" id="bulk-mark-open" data-status="open">
<i data-lucide="rotate-ccw" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkMarkOpen')}
</button>
<button class="btn btn--secondary btn--sm" id="bulk-archive">
<i data-lucide="archive" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkArchive')}
</button>
<button class="btn btn--danger btn--sm" id="bulk-delete">
<i data-lucide="trash-2" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkDelete')}
</button>
</div>
</div>
<div id="task-list"> <div id="task-list">
${[1,2,3].map(() => ` ${[1,2,3].map(() => `
@@ -1691,6 +1821,9 @@ export async function render(container, { user }) {
wireGroupToggle(container); wireGroupToggle(container);
wireNewTaskBtn(container); wireNewTaskBtn(container);
wireTaskList(container); wireTaskList(container);
wireBulkSelect(container);
wireBulkCheckboxes(container);
wireBulkActions(container);
renderFilters(container); renderFilters(container);
renderTaskList(container); renderTaskList(container);
+60 -24
View File
@@ -121,6 +121,7 @@ let currentUser = null;
let currentPath = null; let currentPath = null;
let isNavigating = false; let isNavigating = false;
let _preferencesLoaded = false; let _preferencesLoaded = false;
let _disabledModules = new Set();
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert. // Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt. // Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
let _pendingLoginRedirect = false; let _pendingLoginRedirect = false;
@@ -239,7 +240,15 @@ async function navigate(path, userOrPushState = true, pushState = true) {
const basePath = path.split('?')[0]; const basePath = path.split('?')[0];
currentPath = basePath; 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 // Auth-Guard
if (route.requiresAuth && !currentUser) { if (route.requiresAuth && !currentUser) {
@@ -308,6 +317,9 @@ async function syncPreferencesOnce() {
setAppName(res.data.app_name); setAppName(res.data.app_name);
updateBranding(); updateBranding();
} }
if (Array.isArray(res?.data?.disabled_modules)) {
_disabledModules = new Set(res.data.disabled_modules);
}
} catch { } catch {
// Non-critical. The settings page can refresh this later. // Non-critical. The settings page can refresh this later.
} }
@@ -981,22 +993,32 @@ function renderSearchResults(container, data, onClose) {
} }
function navItems() { function navItems() {
return [ const all = [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' }, { path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard', module: 'dashboard' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' }, { path: '/calendar', label: t('nav.calendar'), icon: 'calendar', module: 'calendar' },
// More-Sheet Items: // More-Sheet Items:
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' }, { path: '/tasks', label: t('nav.tasks'), icon: 'check-square', module: 'tasks' },
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' }, { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake', module: 'birthdays' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/notes', label: t('nav.notes'), icon: 'sticky-note', module: 'notes' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' },
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock' }, { path: '/documents', label: t('nav.documents'), icon: 'folder-lock', module: 'documents' },
{ path: '/settings', label: t('nav.settings'), icon: 'settings' }, { path: '/settings', label: t('nav.settings'), icon: 'settings', module: 'settings' },
// Kitchen-Gruppe: via Küche-Nav-Button (Bottom-Nav + Sidebar) + kitchen-tabs-bar erreichbar // 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: '/meals', label: t('nav.meals'), icon: 'utensils', module: 'meals', kitchenGroup: true },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text', kitchenGroup: true }, { path: '/recipes', label: t('nav.recipes'), icon: 'book-text', module: 'recipes', kitchenGroup: true },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', 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 }) { function navItemEl({ path, label, icon }) {
@@ -1276,8 +1298,9 @@ window.addEventListener('auth:expired', () => {
} }
}); });
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden // Navigation komplett neu rendern (z.B. nach Sprach- oder Modul-Toggle-Änderung).
window.addEventListener('locale-changed', () => { // 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 skipLink = document.querySelector('.sr-only[href="#main-content"]');
const navSidebar = document.querySelector('.nav-sidebar'); const navSidebar = document.querySelector('.nav-sidebar');
const navSidebarItems = document.querySelector('.nav-sidebar__items'); const navSidebarItems = document.querySelector('.nav-sidebar__items');
@@ -1286,10 +1309,14 @@ window.addEventListener('locale-changed', () => {
const moreSheet = document.querySelector('#more-sheet'); const moreSheet = document.querySelector('#more-sheet');
const moreBtnLabel = document.querySelector('#more-btn .nav-item__label'); const moreBtnLabel = document.querySelector('#more-btn .nav-item__label');
if (skipLink) skipLink.textContent = t('common.skipToContent'); if (updateLabels) {
if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); if (skipLink) skipLink.textContent = t('common.skipToContent');
if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more'); 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) { if (navSidebarItems) {
const sidebarEls = []; const sidebarEls = [];
@@ -1297,7 +1324,7 @@ window.addEventListener('locale-changed', () => {
.filter((item) => !item.kitchenGroup) .filter((item) => !item.kitchenGroup)
.forEach((item) => { .forEach((item) => {
sidebarEls.push(navItemEl(item)); sidebarEls.push(navItemEl(item));
if (item.path === '/calendar') sidebarEls.push(sidebarKitchenEl()); if (item.path === '/calendar' && kitchenVisible) sidebarEls.push(sidebarKitchenEl());
}); });
navSidebarItems.replaceChildren(...sidebarEls); navSidebarItems.replaceChildren(...sidebarEls);
if (window.lucide) window.lucide.createIcons({ el: navSidebarItems }); if (window.lucide) window.lucide.createIcons({ el: navSidebarItems });
@@ -1305,9 +1332,13 @@ window.addEventListener('locale-changed', () => {
if (bottomItems) { if (bottomItems) {
const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn'); const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn');
const moreBtn = bottomItems.querySelector('#more-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); 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) { if (moreSheet) {
const handle = moreSheet.querySelector('.more-sheet__handle'); const handle = moreSheet.querySelector('.more-sheet__handle');
@@ -1330,7 +1361,10 @@ window.addEventListener('locale-changed', () => {
updateNav(currentPath); updateNav(currentPath);
updateBranding(currentPath || '/'); updateBranding(currentPath || '/');
}); }
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
window.addEventListener('locale-changed', () => rebuildNavigation());
window.addEventListener('app-name-changed', () => { window.addEventListener('app-name-changed', () => {
updateBranding(currentPath || '/'); updateBranding(currentPath || '/');
@@ -1422,6 +1456,8 @@ window.oikos = {
showToast, showToast,
friendlyError, friendlyError,
setThemeColor, setThemeColor,
setDisabledModules,
isModuleDisabled,
applyTheme: (value) => { applyTheme: (value) => {
localStorage.setItem('oikos-theme', value); localStorage.setItem('oikos-theme', value);
if (value === 'dark') { if (value === 'dark') {
+35
View File
@@ -776,3 +776,38 @@
opacity: 0.5; 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;
}
+33 -1
View File
@@ -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_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']; 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) { function defaultWidgetSize(id) {
if (['tasks', 'calendar'].includes(id)) return '2x2'; if (['tasks', 'calendar'].includes(id)) return '2x2';
if (['weather', 'shopping', 'notes'].includes(id)) return '2x1'; 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) { function normalizeWidgetConfig(input) {
const valid = Array.isArray(input) const valid = Array.isArray(input)
? 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 timeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
const disabledModules = parseDisabledModules(cfgGet('disabled_modules'));
res.json({ res.json({
data: { data: {
@@ -124,6 +143,7 @@ router.get('/', (req, res) => {
time_format: timeFormat, time_format: timeFormat,
app_name: appName, app_name: appName,
dashboard_widgets: dashboardWidgets, dashboard_widgets: dashboardWidgets,
disabled_modules: disabledModules,
}, },
}); });
} catch (err) { } catch (err) {
@@ -141,7 +161,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => { router.put('/', (req, res) => {
try { 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 (visible_meal_types !== undefined) {
if (!Array.isArray(visible_meal_types)) { if (!Array.isArray(visible_meal_types)) {
@@ -190,6 +210,16 @@ router.put('/', (req, res) => {
cfgSet('dashboard_widgets', JSON.stringify(normalized)); 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 rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; 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 savedTimeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules'));
res.json({ res.json({
data: { data: {
@@ -206,6 +237,7 @@ router.put('/', (req, res) => {
time_format: savedTimeFormat, time_format: savedTimeFormat,
app_name: savedAppName, app_name: savedAppName,
dashboard_widgets: savedWidgets, dashboard_widgets: savedWidgets,
disabled_modules: savedDisabledModules,
}, },
}); });
} catch (err) { } catch (err) {