chore: release v0.42.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 }) {
|
||||
<oikos-locale-picker></oikos-locale-picker>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
const mealToggles = container.querySelector('#meal-type-toggles');
|
||||
if (mealToggles) {
|
||||
|
||||
+135
-2
@@ -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 `
|
||||
<div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}">
|
||||
<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}"
|
||||
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) })}">
|
||||
@@ -286,7 +290,10 @@ function renderTaskGroups(tasks, groupMode) {
|
||||
<span class="task-group__title">${catLabelsMap[name] ?? name}</span>
|
||||
<span class="task-group__count">${groupTasks.length}</span>
|
||||
</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>`;
|
||||
}).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 }) {
|
||||
<i data-lucide="columns" class="icon-md" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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)">
|
||||
<i data-lucide="plus" class="icon-lg" aria-hidden="true"></i> ${t('tasks.newTask')}
|
||||
</button>
|
||||
@@ -1647,6 +1756,27 @@ export async function render(container, { user }) {
|
||||
<div class="tasks-body">
|
||||
<div class="tasks-filters" id="filter-bar"></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">
|
||||
${[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);
|
||||
|
||||
|
||||
+56
-20
@@ -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 (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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user