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
+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);