feat: customizable dashboard layout (#32)
Users can now show/hide widgets and reorder them via a settings button in the greeting header. Configuration is persisted server-side in sync_config (dashboard_widgets key) and shared across all family members. - Greeting widget gets a settings icon button opening a customize modal - Modal lists all widgets (tasks, calendar, shopping, meals, notes, weather) with toggle switches and up/down reorder buttons - Reset to default layout available in the modal - GET /preferences now returns dashboard_widgets; PUT accepts it - All 10 locales updated with new i18n keys
This commit is contained in:
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.18.1] - 2026-04-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Customizable dashboard layout: users can now show/hide individual widgets and reorder them via a settings button in the greeting header
|
||||||
|
- New "Anpassen" button (settings icon) in the dashboard greeting widget opens a modal with toggle switches and up/down controls for each widget (Tasks, Calendar, Shopping, Meals, Notes, Weather)
|
||||||
|
- Widget configuration persisted server-side via `dashboard_widgets` preference key in `sync_config` table - survives page reload and applies across all family members
|
||||||
|
- Reset to default layout button in the customize modal
|
||||||
|
- New i18n keys for all 10 supported locales: `dashboard.customize`, `dashboard.customizeTitle`, `dashboard.customizeReset`, `dashboard.customizeSaved`, `dashboard.weather`, `dashboard.customizeMoveUp`, `dashboard.customizeMoveDown`
|
||||||
|
- Backend: `GET /api/v1/preferences` now includes `dashboard_widgets` in the response; `PUT /api/v1/preferences` accepts `dashboard_widgets` array with validation and normalization
|
||||||
|
|
||||||
## [0.18.0] - 2026-04-14
|
## [0.18.0] - 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.18.0",
|
"version": "0.18.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Heute fällig",
|
"dueSoon": "Heute fällig",
|
||||||
"dueTomorrow": "Morgen fällig",
|
"dueTomorrow": "Morgen fällig",
|
||||||
"allDay": "Ganztägig",
|
"allDay": "Ganztägig",
|
||||||
"shoppingMore": "+{{count}} weitere"
|
"shoppingMore": "+{{count}} weitere",
|
||||||
|
"weather": "Wetter",
|
||||||
|
"customize": "Anpassen",
|
||||||
|
"customizeTitle": "Widgets anpassen",
|
||||||
|
"customizeReset": "Standard",
|
||||||
|
"customizeSaved": "Dashboard gespeichert",
|
||||||
|
"customizeMoveUp": "Nach oben",
|
||||||
|
"customizeMoveDown": "Nach unten"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Aufgaben",
|
"title": "Aufgaben",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Λήγει σήμερα",
|
"dueSoon": "Λήγει σήμερα",
|
||||||
"dueTomorrow": "Λήγει αύριο",
|
"dueTomorrow": "Λήγει αύριο",
|
||||||
"allDay": "Όλη μέρα",
|
"allDay": "Όλη μέρα",
|
||||||
"shoppingMore": "+{{count}} ακόμα"
|
"shoppingMore": "+{{count}} ακόμα",
|
||||||
|
"weather": "Καιρός",
|
||||||
|
"customize": "Προσαρμογή",
|
||||||
|
"customizeTitle": "Προσαρμογή widgets",
|
||||||
|
"customizeReset": "Επαναφορά",
|
||||||
|
"customizeSaved": "Πίνακας αποθηκεύτηκε",
|
||||||
|
"customizeMoveUp": "Πάνω",
|
||||||
|
"customizeMoveDown": "Κάτω"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Εργασίες",
|
"title": "Εργασίες",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Due today",
|
"dueSoon": "Due today",
|
||||||
"dueTomorrow": "Due tomorrow",
|
"dueTomorrow": "Due tomorrow",
|
||||||
"allDay": "All day",
|
"allDay": "All day",
|
||||||
"shoppingMore": "+{{count}} more"
|
"shoppingMore": "+{{count}} more",
|
||||||
|
"weather": "Weather",
|
||||||
|
"customize": "Customize",
|
||||||
|
"customizeTitle": "Customize widgets",
|
||||||
|
"customizeReset": "Reset",
|
||||||
|
"customizeSaved": "Dashboard saved",
|
||||||
|
"customizeMoveUp": "Move up",
|
||||||
|
"customizeMoveDown": "Move down"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Tasks",
|
"title": "Tasks",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Vence hoy",
|
"dueSoon": "Vence hoy",
|
||||||
"dueTomorrow": "Vence mañana",
|
"dueTomorrow": "Vence mañana",
|
||||||
"allDay": "Todo el día",
|
"allDay": "Todo el día",
|
||||||
"shoppingMore": "+{{count}} más"
|
"shoppingMore": "+{{count}} más",
|
||||||
|
"weather": "Clima",
|
||||||
|
"customize": "Personalizar",
|
||||||
|
"customizeTitle": "Personalizar widgets",
|
||||||
|
"customizeReset": "Restablecer",
|
||||||
|
"customizeSaved": "Panel guardado",
|
||||||
|
"customizeMoveUp": "Subir",
|
||||||
|
"customizeMoveDown": "Bajar"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Tareas",
|
"title": "Tareas",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "À rendre aujourd'hui",
|
"dueSoon": "À rendre aujourd'hui",
|
||||||
"dueTomorrow": "À rendre demain",
|
"dueTomorrow": "À rendre demain",
|
||||||
"allDay": "Toute la journée",
|
"allDay": "Toute la journée",
|
||||||
"shoppingMore": "+{{count}} de plus"
|
"shoppingMore": "+{{count}} de plus",
|
||||||
|
"weather": "Météo",
|
||||||
|
"customize": "Personnaliser",
|
||||||
|
"customizeTitle": "Personnaliser les widgets",
|
||||||
|
"customizeReset": "Réinitialiser",
|
||||||
|
"customizeSaved": "Tableau de bord sauvegardé",
|
||||||
|
"customizeMoveUp": "Monter",
|
||||||
|
"customizeMoveDown": "Descendre"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Tâches",
|
"title": "Tâches",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Scade oggi",
|
"dueSoon": "Scade oggi",
|
||||||
"dueTomorrow": "Scade domani",
|
"dueTomorrow": "Scade domani",
|
||||||
"allDay": "Tutto il giorno",
|
"allDay": "Tutto il giorno",
|
||||||
"shoppingMore": "+{{count}} altri"
|
"shoppingMore": "+{{count}} altri",
|
||||||
|
"weather": "Meteo",
|
||||||
|
"customize": "Personalizza",
|
||||||
|
"customizeTitle": "Personalizza widget",
|
||||||
|
"customizeReset": "Ripristina",
|
||||||
|
"customizeSaved": "Dashboard salvata",
|
||||||
|
"customizeMoveUp": "Su",
|
||||||
|
"customizeMoveDown": "Giù"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Compiti",
|
"title": "Compiti",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Сегодня",
|
"dueSoon": "Сегодня",
|
||||||
"dueTomorrow": "Завтра",
|
"dueTomorrow": "Завтра",
|
||||||
"allDay": "Весь день",
|
"allDay": "Весь день",
|
||||||
"shoppingMore": "+{{count}} ещё"
|
"shoppingMore": "+{{count}} ещё",
|
||||||
|
"weather": "Погода",
|
||||||
|
"customize": "Настроить",
|
||||||
|
"customizeTitle": "Настроить виджеты",
|
||||||
|
"customizeReset": "Сбросить",
|
||||||
|
"customizeSaved": "Панель сохранена",
|
||||||
|
"customizeMoveUp": "Вверх",
|
||||||
|
"customizeMoveDown": "Вниз"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Задачи",
|
"title": "Задачи",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Förfaller idag",
|
"dueSoon": "Förfaller idag",
|
||||||
"dueTomorrow": "Förfaller imorgon",
|
"dueTomorrow": "Förfaller imorgon",
|
||||||
"allDay": "Hela dagen",
|
"allDay": "Hela dagen",
|
||||||
"shoppingMore": "+{{count}} till"
|
"shoppingMore": "+{{count}} till",
|
||||||
|
"weather": "Väder",
|
||||||
|
"customize": "Anpassa",
|
||||||
|
"customizeTitle": "Anpassa widgets",
|
||||||
|
"customizeReset": "Återställ",
|
||||||
|
"customizeSaved": "Instrumentpanel sparad",
|
||||||
|
"customizeMoveUp": "Flytta upp",
|
||||||
|
"customizeMoveDown": "Flytta ner"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Uppgifter",
|
"title": "Uppgifter",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "Bugün bitiyor",
|
"dueSoon": "Bugün bitiyor",
|
||||||
"dueTomorrow": "Yarın bitiyor",
|
"dueTomorrow": "Yarın bitiyor",
|
||||||
"allDay": "Tüm gün",
|
"allDay": "Tüm gün",
|
||||||
"shoppingMore": "+{{count}} daha"
|
"shoppingMore": "+{{count}} daha",
|
||||||
|
"weather": "Hava",
|
||||||
|
"customize": "Özelleştir",
|
||||||
|
"customizeTitle": "Widget'ları özelleştir",
|
||||||
|
"customizeReset": "Sıfırla",
|
||||||
|
"customizeSaved": "Pano kaydedildi",
|
||||||
|
"customizeMoveUp": "Yukarı",
|
||||||
|
"customizeMoveDown": "Aşağı"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Görevler",
|
"title": "Görevler",
|
||||||
|
|||||||
@@ -77,7 +77,14 @@
|
|||||||
"dueSoon": "今天到期",
|
"dueSoon": "今天到期",
|
||||||
"dueTomorrow": "明天到期",
|
"dueTomorrow": "明天到期",
|
||||||
"allDay": "全天",
|
"allDay": "全天",
|
||||||
"shoppingMore": "+{{count}} 更多"
|
"shoppingMore": "+{{count}} 更多",
|
||||||
|
"weather": "天气",
|
||||||
|
"customize": "自定义",
|
||||||
|
"customizeTitle": "自定义小组件",
|
||||||
|
"customizeReset": "重置",
|
||||||
|
"customizeSaved": "仪表板已保存",
|
||||||
|
"customizeMoveUp": "上移",
|
||||||
|
"customizeMoveDown": "下移"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "任务",
|
"title": "任务",
|
||||||
|
|||||||
+192
-19
@@ -7,10 +7,36 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
|
import { openModal, closeModal } from '/components/modal.js';
|
||||||
|
|
||||||
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
||||||
let _fabController = null;
|
let _fabController = null;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Widget-Definitionen (Reihenfolge = Standard-Layout)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const WIDGET_IDS = ['tasks', 'calendar', 'shopping', 'meals', 'notes', 'weather'];
|
||||||
|
|
||||||
|
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
|
||||||
|
|
||||||
|
function widgetLabel(id) {
|
||||||
|
const map = {
|
||||||
|
tasks: () => t('nav.tasks'),
|
||||||
|
calendar: () => t('nav.calendar'),
|
||||||
|
shopping: () => t('nav.shopping'),
|
||||||
|
meals: () => t('nav.meals'),
|
||||||
|
notes: () => t('nav.notes'),
|
||||||
|
weather: () => t('dashboard.weather'),
|
||||||
|
};
|
||||||
|
return (map[id] ?? (() => id))();
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetIcon(id) {
|
||||||
|
const map = { tasks: 'check-square', calendar: 'calendar', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
|
||||||
|
return map[id] ?? 'layout-dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Hilfsfunktionen
|
// Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -143,11 +169,17 @@ function renderGreeting(user, stats = {}) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="widget-greeting">
|
<div class="widget-greeting">
|
||||||
|
<div class="widget-greeting__inner">
|
||||||
<div class="widget-greeting__content">
|
<div class="widget-greeting__content">
|
||||||
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
||||||
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
|
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="widget-customize-btn" id="dashboard-customize-btn"
|
||||||
|
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
||||||
|
<i data-lucide="settings-2" style="width:16px;height:16px" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -436,6 +468,133 @@ function initFab(container, signal) {
|
|||||||
document.addEventListener('click', () => { if (open) toggleFab(false); }, { signal });
|
document.addEventListener('click', () => { if (open) toggleFab(false); }, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Widget-Rendering nach Konfiguration
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function renderWidgets(cfg, data, weather) {
|
||||||
|
const renderers = {
|
||||||
|
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
|
||||||
|
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
|
||||||
|
shopping: () => renderShoppingLists(data.shoppingLists ?? []),
|
||||||
|
meals: () => renderTodayMeals(data.todayMeals ?? []),
|
||||||
|
notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
|
||||||
|
weather: () => (weather ? renderWeatherWidget(weather) : ''),
|
||||||
|
};
|
||||||
|
return cfg
|
||||||
|
.filter((w) => w.visible)
|
||||||
|
.map((w) => (renderers[w.id] ? renderers[w.id]() : ''))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Customize-Modal
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function openCustomizeModal(currentConfig, onSave) {
|
||||||
|
let draft = currentConfig.map((w) => ({ ...w }));
|
||||||
|
|
||||||
|
function buildRows() {
|
||||||
|
return draft.map((w, i) => {
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === draft.length - 1;
|
||||||
|
return `
|
||||||
|
<div class="customize-row" data-id="${w.id}">
|
||||||
|
<label class="customize-row__toggle">
|
||||||
|
<input type="checkbox" class="customize-row__check" data-id="${w.id}"
|
||||||
|
${w.visible ? 'checked' : ''} aria-label="${widgetLabel(w.id)}">
|
||||||
|
<span class="customize-row__slider" aria-hidden="true"></span>
|
||||||
|
</label>
|
||||||
|
<i data-lucide="${widgetIcon(w.id)}" class="customize-row__icon" aria-hidden="true"></i>
|
||||||
|
<span class="customize-row__name">${widgetLabel(w.id)}</span>
|
||||||
|
<div class="customize-row__actions">
|
||||||
|
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
|
||||||
|
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
|
||||||
|
<i data-lucide="chevron-up" style="width:14px;height:14px" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="customize-row__btn" data-move="down" data-id="${w.id}"
|
||||||
|
${isLast ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveDown')}">
|
||||||
|
<i data-lucide="chevron-down" style="width:14px;height:14px" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: t('dashboard.customizeTitle'),
|
||||||
|
size: 'sm',
|
||||||
|
content: `
|
||||||
|
<div class="customize-list" id="customize-list">${buildRows()}</div>
|
||||||
|
<div class="modal-actions" style="margin-top:var(--space-4)">
|
||||||
|
<button type="button" class="btn btn--ghost" id="customize-reset">${t('dashboard.customizeReset')}</button>
|
||||||
|
<button type="button" class="btn btn--primary" id="customize-save">${t('common.save')}</button>
|
||||||
|
</div>`,
|
||||||
|
onSave(panel) {
|
||||||
|
if (window.lucide) window.lucide.createIcons({ el: panel });
|
||||||
|
|
||||||
|
function rebuildList() {
|
||||||
|
const list = panel.querySelector('#customize-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.replaceChildren();
|
||||||
|
list.insertAdjacentHTML('beforeend', buildRows());
|
||||||
|
if (window.lucide) window.lucide.createIcons({ el: list });
|
||||||
|
wireRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireRows() {
|
||||||
|
const list = panel.querySelector('#customize-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
list.querySelectorAll('.customize-row__check').forEach((cb) => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const id = cb.dataset.id;
|
||||||
|
const entry = draft.find((w) => w.id === id);
|
||||||
|
if (entry) entry.visible = cb.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('[data-move]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const dir = btn.dataset.move;
|
||||||
|
const idx = draft.findIndex((w) => w.id === id);
|
||||||
|
if (dir === 'up' && idx > 0) {
|
||||||
|
[draft[idx - 1], draft[idx]] = [draft[idx], draft[idx - 1]];
|
||||||
|
} else if (dir === 'down' && idx < draft.length - 1) {
|
||||||
|
[draft[idx], draft[idx + 1]] = [draft[idx + 1], draft[idx]];
|
||||||
|
}
|
||||||
|
rebuildList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireRows();
|
||||||
|
|
||||||
|
panel.querySelector('#customize-reset')?.addEventListener('click', () => {
|
||||||
|
draft = DEFAULT_WIDGET_CONFIG.map((w) => ({ ...w }));
|
||||||
|
rebuildList();
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelector('#customize-save')?.addEventListener('click', async () => {
|
||||||
|
const saveBtn = panel.querySelector('#customize-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.put('/preferences', { dashboard_widgets: draft });
|
||||||
|
closeModal();
|
||||||
|
onSave(draft);
|
||||||
|
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
|
||||||
|
} catch {
|
||||||
|
window.oikos?.showToast(t('common.errorGeneric'), 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Navigations-Links verdrahten
|
// Navigations-Links verdrahten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -467,11 +626,13 @@ export async function render(container, { user }) {
|
|||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard__grid">
|
||||||
<div class="widget-greeting" style="grid-column:1/-1">
|
<div class="widget-greeting" style="grid-column:1/-1">
|
||||||
|
<div class="widget-greeting__inner">
|
||||||
<div class="widget-greeting__content">
|
<div class="widget-greeting__content">
|
||||||
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
${skeletonWidget(3)}
|
${skeletonWidget(3)}
|
||||||
${skeletonWidget(3)}
|
${skeletonWidget(3)}
|
||||||
${skeletonWidget(2)}
|
${skeletonWidget(2)}
|
||||||
@@ -483,13 +644,16 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
|
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
|
||||||
let weather = null;
|
let weather = null;
|
||||||
|
let widgetConfig = DEFAULT_WIDGET_CONFIG;
|
||||||
try {
|
try {
|
||||||
const [dashRes, weatherRes] = await Promise.all([
|
const [dashRes, weatherRes, prefsRes] = await Promise.all([
|
||||||
api.get('/dashboard'),
|
api.get('/dashboard'),
|
||||||
api.get('/weather').catch(() => ({ data: null })),
|
api.get('/weather').catch(() => ({ data: null })),
|
||||||
|
api.get('/preferences').catch(() => ({ data: {} })),
|
||||||
]);
|
]);
|
||||||
data = dashRes;
|
data = dashRes;
|
||||||
weather = weatherRes.data ?? null;
|
weather = weatherRes.data ?? null;
|
||||||
|
widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] Ladefehler:', err.message);
|
console.error('[Dashboard] Ladefehler:', err.message);
|
||||||
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
||||||
@@ -506,17 +670,23 @@ export async function render(container, { user }) {
|
|||||||
?? null,
|
?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function rebuildGrid(cfg) {
|
||||||
|
const grid = container.querySelector('.dashboard__grid');
|
||||||
|
const greeting = grid?.querySelector('.widget-greeting');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.replaceChildren(...(greeting ? [greeting] : []));
|
||||||
|
grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
|
||||||
|
wireLinks(container);
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
wireWeatherRefresh(container);
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard__grid">
|
||||||
${renderGreeting(user, stats)}
|
${renderGreeting(user, stats)}
|
||||||
${renderWeatherWidget(weather)}
|
${renderWidgets(widgetConfig, data, weather)}
|
||||||
${renderUrgentTasks(data.urgentTasks ?? [])}
|
|
||||||
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
|
||||||
${renderShoppingLists(data.shoppingLists ?? [])}
|
|
||||||
${renderTodayMeals(data.todayMeals ?? [])}
|
|
||||||
${renderPinnedNotes(data.pinnedNotes ?? [])}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${renderFab()}
|
${renderFab()}
|
||||||
@@ -526,30 +696,33 @@ export async function render(container, { user }) {
|
|||||||
initFab(container, _fabController.signal);
|
initFab(container, _fabController.signal);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|
||||||
// Wetter-Refresh: Button + 30-Minuten-Interval
|
container.querySelector('#dashboard-customize-btn')?.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => openCustomizeModal(widgetConfig, (newConfig) => {
|
||||||
|
widgetConfig = newConfig;
|
||||||
|
rebuildGrid(widgetConfig);
|
||||||
|
}),
|
||||||
|
{ signal: _fabController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
wireWeatherRefresh(container);
|
||||||
|
|
||||||
|
// 30-Minuten Auto-Refresh für Wetter
|
||||||
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
const doWeatherRefresh = async () => {
|
const doAutoRefresh = async () => {
|
||||||
refreshBtn.disabled = true;
|
|
||||||
refreshBtn.classList.add('weather-widget__refresh--spinning');
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/weather').catch(() => ({ data: null }));
|
const res = await api.get('/weather').catch(() => ({ data: null }));
|
||||||
const wWidget = container.querySelector('#weather-widget');
|
const wWidget = container.querySelector('#weather-widget');
|
||||||
if (wWidget) {
|
if (wWidget) {
|
||||||
const fresh = renderWeatherWidget(res.data ?? null);
|
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
||||||
wWidget.outerHTML = fresh;
|
|
||||||
const newWidget = container.querySelector('#weather-widget');
|
const newWidget = container.querySelector('#weather-widget');
|
||||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
||||||
wireWeatherRefresh(container);
|
wireWeatherRefresh(container);
|
||||||
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
|
|
||||||
}
|
}
|
||||||
} catch { /* silently ignore */ }
|
} catch { /* silently ignore */ }
|
||||||
};
|
};
|
||||||
|
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
|
||||||
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
|
|
||||||
|
|
||||||
// 30-Minuten Auto-Refresh - abortiert wenn Seite verlassen wird
|
|
||||||
const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000);
|
|
||||||
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,10 +98,19 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-greeting__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.widget-greeting__content {
|
.widget-greeting__content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-0h);
|
gap: var(--space-0h);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-greeting__title {
|
.widget-greeting__title {
|
||||||
@@ -1020,3 +1029,143 @@
|
|||||||
.fab-action__btn:hover {
|
.fab-action__btn:hover {
|
||||||
background-color: var(--color-accent-light);
|
background-color: var(--color-accent-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Widget-Customize-Button (im Greeting-Header)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.widget-customize-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(255 255 255 / 0.18);
|
||||||
|
border: 1px solid rgba(255 255 255 / 0.3);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-customize-btn:hover,
|
||||||
|
.widget-customize-btn:focus-visible {
|
||||||
|
background: rgba(255 255 255 / 0.3);
|
||||||
|
outline: 2px solid rgba(255 255 255 / 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Dashboard-Customize-Modal
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.customize-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row:hover {
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__check {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__slider {
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-border);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__check:checked + .customize-row__slider {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__check:checked + .customize-row__slider::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__check:focus-visible + .customize-row__slider {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-row__btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
|
|||||||
const VALID_CURRENCIES = ['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'CNY', 'PLN', 'CZK', 'HUF', 'JPY', 'AUD', 'CAD', 'TRY', 'RUB'];
|
const VALID_CURRENCIES = ['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'CNY', 'PLN', 'CZK', 'HUF', 'JPY', 'AUD', 'CAD', 'TRY', 'RUB'];
|
||||||
const DEFAULT_CURRENCY = 'EUR';
|
const DEFAULT_CURRENCY = 'EUR';
|
||||||
|
|
||||||
|
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'shopping', 'meals', 'notes', 'weather'];
|
||||||
|
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Hilfsfunktionen
|
// Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -36,6 +39,34 @@ function cfgSet(key, value) {
|
|||||||
`).run(key, value);
|
`).run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Widget-Hilfsfunktionen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function parseWidgetConfig(raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw ?? DEFAULT_WIDGET_CONFIG);
|
||||||
|
return normalizeWidgetConfig(parsed);
|
||||||
|
} catch {
|
||||||
|
return JSON.parse(DEFAULT_WIDGET_CONFIG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWidgetConfig(input) {
|
||||||
|
const valid = Array.isArray(input)
|
||||||
|
? input
|
||||||
|
.filter((w) => w && typeof w === 'object' && VALID_WIDGET_IDS.includes(w.id))
|
||||||
|
.map((w) => ({ id: w.id, visible: Boolean(w.visible) }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Fehlende Widget-IDs am Ende ergänzen
|
||||||
|
const presentIds = new Set(valid.map((w) => w.id));
|
||||||
|
for (const id of VALID_WIDGET_IDS) {
|
||||||
|
if (!presentIds.has(id)) valid.push({ id, visible: true });
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// GET /api/v1/preferences
|
// GET /api/v1/preferences
|
||||||
// Alle Haushalt-Praeferenzen lesen.
|
// Alle Haushalt-Praeferenzen lesen.
|
||||||
@@ -47,11 +78,13 @@ router.get('/', (req, res) => {
|
|||||||
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
||||||
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||||
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
||||||
|
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: visibleMealTypes,
|
visible_meal_types: visibleMealTypes,
|
||||||
currency,
|
currency,
|
||||||
|
dashboard_widgets: dashboardWidgets,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -69,7 +102,7 @@ router.get('/', (req, res) => {
|
|||||||
|
|
||||||
router.put('/', (req, res) => {
|
router.put('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { visible_meal_types, currency } = req.body;
|
const { visible_meal_types, currency, dashboard_widgets } = req.body;
|
||||||
|
|
||||||
if (visible_meal_types !== undefined) {
|
if (visible_meal_types !== undefined) {
|
||||||
if (!Array.isArray(visible_meal_types)) {
|
if (!Array.isArray(visible_meal_types)) {
|
||||||
@@ -89,14 +122,24 @@ router.put('/', (req, res) => {
|
|||||||
cfgSet('currency', currency);
|
cfgSet('currency', currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dashboard_widgets !== undefined) {
|
||||||
|
if (!Array.isArray(dashboard_widgets)) {
|
||||||
|
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
|
||||||
|
}
|
||||||
|
const normalized = normalizeWidgetConfig(dashboard_widgets);
|
||||||
|
cfgSet('dashboard_widgets', JSON.stringify(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: savedMealTypes,
|
visible_meal_types: savedMealTypes,
|
||||||
currency: savedCurrency,
|
currency: savedCurrency,
|
||||||
|
dashboard_widgets: savedWidgets,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user