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
+15
View File
@@ -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.",
+15
View File
@@ -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.",
+46 -1
View File
@@ -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
View File
@@ -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);
+60 -24
View File
@@ -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') {
+35
View File
@@ -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;
}