Add dashboard widget customization

This commit is contained in:
Rafael Foster
2026-05-01 08:53:25 -03:00
parent e34ba33f9b
commit 9c5f8c9a99
18 changed files with 534 additions and 70 deletions
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "للأعلى",
"customizeMoveDown": "للأسفل",
"overdueTasksChip": "{{count}} مهمة متأخرة",
"overdueTasksChipPlural": "{{count}} مهام متأخرة"
"overdueTasksChipPlural": "{{count}} مهام متأخرة",
"customizeManage": "الأدوات",
"customizeExit": "إنهاء التخصيص",
"customizeDrag": "اسحب الأداة",
"customizeSize": "الحجم",
"customizeSizeFor": "حجم {{widget}}",
"customizeHide": "إخفاء {{widget}}"
},
"tasks": {
"title": "المهام",
+7 -1
View File
@@ -118,7 +118,13 @@
"customizeMoveUp": "Nach oben",
"customizeMoveDown": "Nach unten",
"overdueTasksChip": "{{count}} überfällige Aufgabe",
"overdueTasksChipPlural": "{{count}} überfällige Aufgaben"
"overdueTasksChipPlural": "{{count}} überfällige Aufgaben",
"customizeManage": "Widgets",
"customizeExit": "Anpassung beenden",
"customizeDrag": "Widget ziehen",
"customizeSize": "Größe",
"customizeSizeFor": "Größe für {{widget}}",
"customizeHide": "{{widget}} ausblenden"
},
"tasks": {
"title": "Aufgaben",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Πάνω",
"customizeMoveDown": "Κάτω",
"overdueTasksChip": "{{count}} εκπρόθεσμη εργασία",
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες"
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες",
"customizeManage": "Widget",
"customizeExit": "Έξοδος από προσαρμογή",
"customizeDrag": "Σύρετε widget",
"customizeSize": "Μέγεθος",
"customizeSizeFor": "Μέγεθος για {{widget}}",
"customizeHide": "Απόκρυψη {{widget}}"
},
"tasks": {
"title": "Εργασίες",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Move up",
"customizeMoveDown": "Move down",
"overdueTasksChip": "{{count}} overdue task",
"overdueTasksChipPlural": "{{count}} overdue tasks"
"overdueTasksChipPlural": "{{count}} overdue tasks",
"customizeManage": "Widgets",
"customizeExit": "Exit customization",
"customizeDrag": "Drag widget",
"customizeSize": "Size",
"customizeSizeFor": "Size for {{widget}}",
"customizeHide": "Hide {{widget}}"
},
"tasks": {
"title": "Tasks",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Subir",
"customizeMoveDown": "Bajar",
"overdueTasksChip": "{{count}} tarea vencida",
"overdueTasksChipPlural": "{{count}} tareas vencidas"
"overdueTasksChipPlural": "{{count}} tareas vencidas",
"customizeManage": "Widgets",
"customizeExit": "Salir de personalización",
"customizeDrag": "Arrastrar widget",
"customizeSize": "Tamaño",
"customizeSizeFor": "Tamaño de {{widget}}",
"customizeHide": "Ocultar {{widget}}"
},
"tasks": {
"title": "Tareas",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Monter",
"customizeMoveDown": "Descendre",
"overdueTasksChip": "{{count}} tâche en retard",
"overdueTasksChipPlural": "{{count}} tâches en retard"
"overdueTasksChipPlural": "{{count}} tâches en retard",
"customizeManage": "Widgets",
"customizeExit": "Quitter la personnalisation",
"customizeDrag": "Faire glisser le widget",
"customizeSize": "Taille",
"customizeSizeFor": "Taille de {{widget}}",
"customizeHide": "Masquer {{widget}}"
},
"tasks": {
"title": "Tâches",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "ऊपर ले जाएं",
"customizeMoveDown": "नीचे ले जाएं",
"overdueTasksChip": "{{count}} विलंबित कार्य",
"overdueTasksChipPlural": "{{count}} विलंबित कार्य"
"overdueTasksChipPlural": "{{count}} विलंबित कार्य",
"customizeManage": "विजेट",
"customizeExit": "अनुकूलन से बाहर निकलें",
"customizeDrag": "विजेट खींचें",
"customizeSize": "आकार",
"customizeSizeFor": "{{widget}} का आकार",
"customizeHide": "{{widget}} छिपाएँ"
},
"tasks": {
"title": "कार्य",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Su",
"customizeMoveDown": "Giù",
"overdueTasksChip": "{{count}} compito scaduto",
"overdueTasksChipPlural": "{{count}} compiti scaduti"
"overdueTasksChipPlural": "{{count}} compiti scaduti",
"customizeManage": "Widget",
"customizeExit": "Esci dalla personalizzazione",
"customizeDrag": "Trascina widget",
"customizeSize": "Dimensione",
"customizeSizeFor": "Dimensione di {{widget}}",
"customizeHide": "Nascondi {{widget}}"
},
"tasks": {
"title": "Compiti",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "上へ",
"customizeMoveDown": "下へ",
"overdueTasksChip": "期限超過のタスク {{count}} 件",
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件"
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件",
"customizeManage": "ウィジェット",
"customizeExit": "カスタマイズを終了",
"customizeDrag": "ウィジェットをドラッグ",
"customizeSize": "サイズ",
"customizeSizeFor": "{{widget}} のサイズ",
"customizeHide": "{{widget}} を非表示"
},
"tasks": {
"title": "タスク",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Mover para cima",
"customizeMoveDown": "Mover para baixo",
"overdueTasksChip": "{{count}} tarefa vencida",
"overdueTasksChipPlural": "{{count}} tarefas vencidas"
"overdueTasksChipPlural": "{{count}} tarefas vencidas",
"customizeManage": "Widgets",
"customizeExit": "Sair da personalização",
"customizeDrag": "Arrastar widget",
"customizeSize": "Tamanho",
"customizeSizeFor": "Tamanho de {{widget}}",
"customizeHide": "Ocultar {{widget}}"
},
"tasks": {
"title": "Tarefas",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Вверх",
"customizeMoveDown": "Вниз",
"overdueTasksChip": "{{count}} просроченная задача",
"overdueTasksChipPlural": "{{count}} просроченных задач"
"overdueTasksChipPlural": "{{count}} просроченных задач",
"customizeManage": "Виджеты",
"customizeExit": "Выйти из настройки",
"customizeDrag": "Перетащить виджет",
"customizeSize": "Размер",
"customizeSizeFor": "Размер для {{widget}}",
"customizeHide": "Скрыть {{widget}}"
},
"tasks": {
"title": "Задачи",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Flytta upp",
"customizeMoveDown": "Flytta ner",
"overdueTasksChip": "{{count}} förfallen uppgift",
"overdueTasksChipPlural": "{{count}} förfallna uppgifter"
"overdueTasksChipPlural": "{{count}} förfallna uppgifter",
"customizeManage": "Widgetar",
"customizeExit": "Avsluta anpassning",
"customizeDrag": "Dra widget",
"customizeSize": "Storlek",
"customizeSizeFor": "Storlek för {{widget}}",
"customizeHide": "Dölj {{widget}}"
},
"tasks": {
"title": "Uppgifter",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Yukarı",
"customizeMoveDown": "Aşağı",
"overdueTasksChip": "{{count}} gecikmiş görev",
"overdueTasksChipPlural": "{{count}} gecikmiş görev"
"overdueTasksChipPlural": "{{count}} gecikmiş görev",
"customizeManage": "Widgetlar",
"customizeExit": "Özelleştirmeden çık",
"customizeDrag": "Widgetı sürükle",
"customizeSize": "Boyut",
"customizeSizeFor": "{{widget}} boyutu",
"customizeHide": "{{widget}} gizle"
},
"tasks": {
"title": "Görevler",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "Перемістити вгору",
"customizeMoveDown": "Перемістити вниз",
"overdueTasksChip": "{{count}} прострочене завдання",
"overdueTasksChipPlural": "{{count}} прострочених завдань"
"overdueTasksChipPlural": "{{count}} прострочених завдань",
"customizeManage": "Віджети",
"customizeExit": "Вийти з налаштування",
"customizeDrag": "Перетягнути віджет",
"customizeSize": "Розмір",
"customizeSizeFor": "Розмір для {{widget}}",
"customizeHide": "Приховати {{widget}}"
},
"tasks": {
"title": "Завдання",
+7 -1
View File
@@ -112,7 +112,13 @@
"customizeMoveUp": "上移",
"customizeMoveDown": "下移",
"overdueTasksChip": "{{count}} 个逾期任务",
"overdueTasksChipPlural": "{{count}} 个逾期任务"
"overdueTasksChipPlural": "{{count}} 个逾期任务",
"customizeManage": "小组件",
"customizeExit": "退出自定义",
"customizeDrag": "拖动小组件",
"customizeSize": "大小",
"customizeSizeFor": "{{widget}} 的大小",
"customizeHide": "隐藏 {{widget}}"
},
"tasks": {
"title": "任务",
+234 -28
View File
@@ -113,7 +113,52 @@ function showOnboarding(appContainer) {
// NEU — primäre Inhalte (tasks, calendar) ganz oben
const WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id, i) => ({ id, visible: true, order: i }));
const WIDGET_SIZE_OPTIONS = ['1x1', '2x1', '2x2', '3x1', '3x2', '4x1', '4x2'];
const WIDGET_SIZE_LABELS = {
'1x1': '1x1',
'2x1': '2x1',
'2x2': '2x2',
'3x1': '3x1',
'3x2': '3x2',
'4x1': '4x1',
'4x2': '4x2',
};
function defaultWidgetSize(id) {
if (['tasks', 'calendar'].includes(id)) return '2x2';
if (['weather', 'shopping'].includes(id)) return '2x1';
if (id === 'notes') return '2x1';
return '1x1';
}
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id, i) => ({ id, visible: true, order: i, size: defaultWidgetSize(id) }));
function normalizeDashboardConfig(input) {
const valid = Array.isArray(input)
? input
.filter((w) => w && typeof w === 'object' && WIDGET_IDS.includes(w.id))
.map((w, i) => ({
id: w.id,
visible: w.visible !== false,
order: Number.isFinite(Number(w.order)) ? Number(w.order) : i,
size: WIDGET_SIZE_OPTIONS.includes(w.size) ? w.size : defaultWidgetSize(w.id),
}))
: [];
const presentIds = new Set(valid.map((w) => w.id));
for (const id of WIDGET_IDS) {
if (!presentIds.has(id)) {
valid.push({ id, visible: true, order: valid.length, size: defaultWidgetSize(id) });
}
}
return valid
.sort((a, b) => a.order - b.order)
.map((w, i) => ({ ...w, order: i }));
}
function setHtml(element, html) {
element.replaceChildren();
element.insertAdjacentHTML('afterbegin', html);
}
function widgetLabel(id) {
const map = {
@@ -523,7 +568,7 @@ function renderQuickAction({ route, label, icon, tone = '' }) {
}
function renderDashboardOverview(user) {
function renderDashboardOverview(user, editing = false) {
const dateLabel = formatDate(new Date());
const actions = [
@@ -541,10 +586,21 @@ function renderDashboardOverview(user) {
<h1 class="dashboard-overview__title">${greeting(user.display_name)}</h1>
</div>
<div class="dashboard-overview__tools">
<div class="dashboard-overview__actions">${actions}</div>
${editing ? `
<div class="dashboard-customize-toolbar" role="toolbar" aria-label="${t('dashboard.customizeTitle')}">
<button class="btn btn--secondary" id="dashboard-manage-widgets">
<i data-lucide="sliders-horizontal" aria-hidden="true"></i>
${t('dashboard.customizeManage')}
</button>
<button class="btn btn--ghost" id="dashboard-customize-reset">${t('dashboard.customizeReset')}</button>
<button class="btn btn--secondary" id="dashboard-customize-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" id="dashboard-customize-save">${t('common.save')}</button>
</div>` : `<div class="dashboard-overview__actions">${actions}</div>`}
<button class="dashboard-icon-btn" id="dashboard-customize-btn"
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
<i data-lucide="settings-2" aria-hidden="true"></i>
aria-label="${editing ? t('dashboard.customizeExit') : t('dashboard.customize')}"
title="${editing ? t('dashboard.customizeExit') : t('dashboard.customize')}"
aria-pressed="${editing ? 'true' : 'false'}">
<i data-lucide="${editing ? 'x' : 'settings-2'}" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -552,17 +608,34 @@ function renderDashboardOverview(user) {
`;
}
function widgetTileClass(id) {
// Primär: immer 2 Spalten breit (die wichtigsten Inhalte)
const primaryIds = ['tasks', 'calendar'];
// Sekundär: 2 Spalten ab 3-Spalten-Breakpoint (1024px)
const secondaryIds = ['weather', 'shopping'];
if (primaryIds.includes(id)) return 'widget--wide';
if (secondaryIds.includes(id)) return 'widget--secondary';
return '';
function widgetSizeClass(size) {
return WIDGET_SIZE_OPTIONS.includes(size) ? `widget-size--${size}` : 'widget-size--1x1';
}
function renderDashboardLayout(cfg, data, weather, currency) {
function renderWidgetCustomizeControls(w) {
const sizeOptions = WIDGET_SIZE_OPTIONS.map((size) => `
<option value="${size}" ${w.size === size ? 'selected' : ''}>${WIDGET_SIZE_LABELS[size]}</option>
`).join('');
return `
<div class="widget-edit-controls" data-widget-controls>
<button type="button" class="widget-edit-controls__handle" data-widget-drag-handle aria-label="${t('dashboard.customizeDrag')}">
<i data-lucide="grip-vertical" aria-hidden="true"></i>
</button>
<label class="widget-edit-controls__size">
<span>${t('dashboard.customizeSize')}</span>
<select class="widget-edit-controls__select" data-widget-size="${esc(w.id)}" aria-label="${t('dashboard.customizeSizeFor', { widget: widgetLabel(w.id) })}">
${sizeOptions}
</select>
</label>
<button type="button" class="widget-edit-controls__hide" data-widget-hide="${esc(w.id)}" aria-label="${t('dashboard.customizeHide', { widget: widgetLabel(w.id) })}">
<i data-lucide="eye-off" aria-hidden="true"></i>
</button>
</div>
`;
}
function renderDashboardLayout(cfg, data, weather, currency, { editing = false } = {}) {
const widgetById = {
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
@@ -580,11 +653,15 @@ function renderDashboardLayout(cfg, data, weather, currency) {
.map((w) => {
const html = widgetById[w.id]();
if (!html) return '';
return `<div class="widget-wrapper ${widgetTileClass(w.id)}">${html}</div>`;
return `<div class="widget-wrapper ${widgetSizeClass(w.size)} ${editing ? 'widget-wrapper--editing' : ''}"
data-widget-id="${esc(w.id)}" ${editing ? 'draggable="true"' : ''}>
${editing ? renderWidgetCustomizeControls(w) : ''}
${html}
</div>`;
})
.join('');
return `<div class="dashboard__grid">${tiles}</div>`;
return `<div class="dashboard__grid ${editing ? 'dashboard__grid--editing' : ''}" id="dashboard-widget-grid">${tiles}</div>`;
}
function renderDashboardSkeleton() {
@@ -800,6 +877,9 @@ function openCustomizeModal(currentConfig, onSave) {
return draft.map((w, i) => {
const isFirst = i === 0;
const isLast = i === draft.length - 1;
const sizeOptions = WIDGET_SIZE_OPTIONS.map((size) => `
<option value="${size}" ${w.size === size ? 'selected' : ''}>${WIDGET_SIZE_LABELS[size]}</option>
`).join('');
return `
<div class="customize-row" data-id="${esc(w.id)}" style="view-transition-name: widget-row-${esc(w.id)}">
<label class="customize-row__toggle">
@@ -809,6 +889,12 @@ function openCustomizeModal(currentConfig, onSave) {
</label>
<i data-lucide="${widgetIcon(w.id)}" class="customize-row__icon" aria-hidden="true"></i>
<span class="customize-row__name">${widgetLabel(w.id)}</span>
<label class="customize-row__size">
<span>${t('dashboard.customizeSize')}</span>
<select class="form-input customize-row__select" data-size-id="${esc(w.id)}" aria-label="${t('dashboard.customizeSizeFor', { widget: widgetLabel(w.id) })}">
${sizeOptions}
</select>
</label>
<div class="customize-row__actions">
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
@@ -880,6 +966,13 @@ function openCustomizeModal(currentConfig, onSave) {
});
});
list.querySelectorAll('[data-size-id]').forEach((select) => {
select.addEventListener('change', () => {
const entry = draft.find((w) => w.id === select.dataset.sizeId);
if (entry && WIDGET_SIZE_OPTIONS.includes(select.value)) entry.size = select.value;
});
});
list.querySelectorAll('.customize-row').forEach((row, idx) => {
row.setAttribute('draggable', 'true');
row.addEventListener('dragstart', (e) => {
@@ -975,9 +1068,10 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
// Navigations-Links verdrahten
// --------------------------------------------------------
function wireLinks(container, rerender) {
function wireLinks(container, rerender, { editing = false } = {}) {
container.querySelectorAll('[data-route]').forEach((el) => {
if (el.id === 'fab-main' || el.closest('#fab-actions')) return;
if (editing && el.closest('.widget-wrapper--editing')) return;
const go = () => window.oikos.navigate(el.dataset.route);
if (el.tagName === 'A') {
el.addEventListener('click', (e) => { e.preventDefault(); go(); });
@@ -990,6 +1084,7 @@ function wireLinks(container, rerender) {
});
// Task-Items öffnen Quick-Action-Modal statt direkt zu navigieren
if (editing) return;
container.querySelectorAll('.task-item[data-task-id]').forEach((el) => {
const show = () => openTaskQuickAction(el.dataset.taskId, el.dataset.taskTitle, rerender);
el.addEventListener('click', show);
@@ -999,6 +1094,21 @@ function wireLinks(container, rerender) {
});
}
function reorderWidgetConfig(config, fromId, toId) {
const fromIdx = config.findIndex((w) => w.id === fromId);
const toIdx = config.findIndex((w) => w.id === toId);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return config;
const next = config.map((w) => ({ ...w }));
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
return next.map((w, i) => ({ ...w, order: i }));
}
function updateWidgetConfig(config, id, patch) {
return config.map((w) => w.id === id ? { ...w, ...patch } : w)
.map((w, i) => ({ ...w, order: i }));
}
// --------------------------------------------------------
// Haupt-Render
// --------------------------------------------------------
@@ -1007,7 +1117,7 @@ export async function render(container, { user }) {
_fabController?.abort();
_fabController = new AbortController();
container.innerHTML = `
setHtml(container, `
<div class="dashboard">
<h1 class="sr-only">${t('dashboard.title')}</h1>
<div class="dashboard-shell" id="dashboard-shell">
@@ -1015,11 +1125,13 @@ export async function render(container, { user }) {
</div>
</div>
${renderFab()}
`;
`);
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} };
let weather = null;
let widgetConfig = DEFAULT_WIDGET_CONFIG;
let savedWidgetConfig = DEFAULT_WIDGET_CONFIG;
let isCustomizing = false;
let currency = 'EUR';
try {
const [dashRes, weatherRes, prefsRes] = await Promise.all([
@@ -1029,8 +1141,8 @@ export async function render(container, { user }) {
]);
data = dashRes;
weather = weatherRes.data ?? null;
const raw = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
widgetConfig = raw.map((w, i) => ({ order: i, ...w })).sort((a, b) => a.order - b.order);
widgetConfig = normalizeDashboardConfig(prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG);
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
currency = prefsRes.data?.currency ?? 'EUR';
} catch (err) {
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
@@ -1039,26 +1151,116 @@ export async function render(container, { user }) {
const rerender = () => render(container, { user });
async function saveDashboardConfig() {
try {
await api.put('/preferences', { dashboard_widgets: widgetConfig });
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
isCustomizing = false;
rebuildDashboard(widgetConfig);
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
} catch {
window.oikos?.showToast(t('common.errorGeneric'), 'error');
}
}
function cancelDashboardConfig() {
widgetConfig = savedWidgetConfig.map((w) => ({ ...w }));
isCustomizing = false;
rebuildDashboard(widgetConfig);
}
function resetDashboardConfig() {
widgetConfig = DEFAULT_WIDGET_CONFIG.map((w) => ({ ...w }));
rebuildDashboard(widgetConfig);
}
function wireDashboardEditMode() {
if (!isCustomizing) return;
const grid = container.querySelector('#dashboard-widget-grid');
if (!grid) return;
let draggedId = '';
grid.querySelectorAll('.widget-wrapper[data-widget-id]').forEach((wrapper) => {
wrapper.addEventListener('dragstart', (event) => {
draggedId = wrapper.dataset.widgetId;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedId);
wrapper.classList.add('widget-wrapper--dragging');
});
wrapper.addEventListener('dragend', () => {
draggedId = '';
wrapper.classList.remove('widget-wrapper--dragging');
grid.querySelectorAll('.widget-wrapper--drag-over').forEach((el) => el.classList.remove('widget-wrapper--drag-over'));
});
wrapper.addEventListener('dragover', (event) => {
event.preventDefault();
if (draggedId && draggedId !== wrapper.dataset.widgetId) {
wrapper.classList.add('widget-wrapper--drag-over');
event.dataTransfer.dropEffect = 'move';
}
});
wrapper.addEventListener('dragleave', () => {
wrapper.classList.remove('widget-wrapper--drag-over');
});
wrapper.addEventListener('drop', (event) => {
event.preventDefault();
wrapper.classList.remove('widget-wrapper--drag-over');
const fromId = event.dataTransfer.getData('text/plain') || draggedId;
const toId = wrapper.dataset.widgetId;
widgetConfig = reorderWidgetConfig(widgetConfig, fromId, toId);
rebuildDashboard(widgetConfig);
});
});
grid.querySelectorAll('[data-widget-size]').forEach((select) => {
select.addEventListener('change', () => {
if (!WIDGET_SIZE_OPTIONS.includes(select.value)) return;
widgetConfig = updateWidgetConfig(widgetConfig, select.dataset.widgetSize, { size: select.value });
rebuildDashboard(widgetConfig);
});
});
grid.querySelectorAll('[data-widget-hide]').forEach((btn) => {
btn.addEventListener('click', () => {
widgetConfig = updateWidgetConfig(widgetConfig, btn.dataset.widgetHide, { visible: false });
rebuildDashboard(widgetConfig);
});
});
}
function rebuildDashboard(cfg) {
const shell = container.querySelector('#dashboard-shell');
if (!shell) return;
shell.replaceChildren();
shell.insertAdjacentHTML('beforeend', `
${renderDashboardOverview(user)}
${renderDashboardLayout(cfg, data, weather, currency)}
setHtml(shell, `
${renderDashboardOverview(user, isCustomizing)}
${renderDashboardLayout(cfg, data, weather, currency, { editing: isCustomizing })}
`);
wireLinks(container, rerender);
wireLinks(container, rerender, { editing: isCustomizing });
if (window.lucide) window.lucide.createIcons();
wireWeatherRefresh(container, (updatedWeather) => {
weather = updatedWeather;
rebuildDashboard(cfg);
});
container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => {
isCustomizing = !isCustomizing;
if (!isCustomizing) {
cancelDashboardConfig();
return;
}
rebuildDashboard(widgetConfig);
}, { signal: _fabController.signal });
container.querySelector('#dashboard-manage-widgets')?.addEventListener('click', () => {
openCustomizeModal(widgetConfig, (newConfig) => {
widgetConfig = newConfig;
widgetConfig = normalizeDashboardConfig(newConfig);
savedWidgetConfig = widgetConfig.map((w) => ({ ...w }));
isCustomizing = false;
rebuildDashboard(widgetConfig);
});
}, { signal: _fabController.signal });
container.querySelector('#dashboard-customize-save')?.addEventListener('click', saveDashboardConfig, { signal: _fabController.signal });
container.querySelector('#dashboard-customize-cancel')?.addEventListener('click', cancelDashboardConfig, { signal: _fabController.signal });
container.querySelector('#dashboard-customize-reset')?.addEventListener('click', resetDashboardConfig, { signal: _fabController.signal });
wireDashboardEditMode();
}
rebuildDashboard(widgetConfig);
@@ -1094,7 +1296,11 @@ function wireWeatherRefresh(container, onUpdated = null) {
const res = await api.get('/weather').catch(() => ({ data: null }));
const wWidget = container.querySelector('#weather-widget');
if (wWidget) {
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
const wrapper = wWidget.closest('.widget-wrapper');
if (wrapper) {
wrapper.querySelector('.widget')?.remove();
wrapper.insertAdjacentHTML('beforeend', renderWeatherWidget(res.data ?? null));
}
const newWidget = container.querySelector('#weather-widget');
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
onUpdated?.(res.data ?? null);
+168 -22
View File
@@ -172,10 +172,13 @@
.widget-wrapper {
display: flex;
flex-direction: column;
position: relative;
min-width: 0;
}
.widget-wrapper > .widget {
flex: 1;
min-height: 0;
}
@media (min-width: 768px) {
@@ -183,29 +186,46 @@
grid-template-columns: repeat(2, 1fr);
}
.widget--wide {
grid-column: 1 / -1;
}
/* Sekundäre Widgets bei 2 Spalten: 1 Spalte */
.widget--secondary {
grid-column: span 1;
.widget-size--2x1,
.widget-size--2x2,
.widget-size--3x1,
.widget-size--3x2,
.widget-size--4x1,
.widget-size--4x2 {
grid-column: span 2;
}
}
@media (min-width: 1024px) {
.dashboard__grid {
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: minmax(132px, auto);
gap: var(--space-5);
}
.widget--wide {
.widget-size--2x1,
.widget-size--2x2 {
grid-column: span 2;
}
/* Sekundäre Widgets: 2 Spalten ab 3-Spalten-Grid */
.widget--secondary {
grid-column: span 2;
.widget-size--3x1,
.widget-size--3x2,
.widget-size--4x1,
.widget-size--4x2 {
grid-column: span 3;
}
.widget-size--1x1,
.widget-size--2x1,
.widget-size--3x1,
.widget-size--4x1 {
grid-row: span 1;
}
.widget-size--2x2,
.widget-size--3x2,
.widget-size--4x2 {
grid-row: span 2;
}
}
@@ -214,24 +234,25 @@
grid-template-columns: repeat(4, 1fr);
}
/* Wide-Widgets nehmen 2 von 4 Spalten ein */
.widget--wide {
.widget-size--1x1 { grid-column: span 1; }
.widget-size--2x1,
.widget-size--2x2 {
grid-column: span 2;
}
/* Im 4-Spalten-Grid: sekundäre Widgets nur 1 Spalte */
.widget--secondary {
grid-column: span 1;
.widget-size--3x1,
.widget-size--3x2 {
grid-column: span 3;
}
/* Greeting-Widget über alle 4 Spalten */
.widget-greeting {
grid-column: 1 / -1;
.widget-size--4x1,
.widget-size--4x2 {
grid-column: span 4;
}
}
/* Primäre Widgets: subtile Akzentlinie oben */
.widget-wrapper.widget--wide > .widget {
.widget-size--2x2 > .widget,
.widget-size--3x2 > .widget,
.widget-size--4x2 > .widget {
border-top: 2px solid var(--active-module-accent, var(--color-accent));
}
@@ -425,6 +446,8 @@
.widget__body {
flex: 1;
padding: var(--space-2) var(--space-4) var(--space-4);
min-height: 0;
overflow: auto;
}
.widget__empty {
@@ -1367,6 +1390,21 @@
font-weight: var(--font-weight-medium);
}
.customize-row__size {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.customize-row__select {
min-height: 30px;
width: 86px;
padding: 0 var(--space-1);
font-size: var(--text-xs);
}
.customize-row__actions {
display: flex;
gap: var(--space-1);
@@ -2090,6 +2128,114 @@
height: 18px;
}
.dashboard-customize-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
flex-wrap: wrap;
}
.dashboard-customize-toolbar .btn {
min-height: 36px;
}
.dashboard-customize-toolbar .btn i {
width: 16px;
height: 16px;
}
.dashboard__grid--editing {
padding: var(--space-2);
border: 1px dashed color-mix(in srgb, var(--module-accent) 48%, var(--color-border));
border-radius: 8px;
background: color-mix(in srgb, var(--module-accent) 4%, transparent);
}
.widget-wrapper--editing {
padding-top: 42px;
outline: 1px solid color-mix(in srgb, var(--module-accent) 36%, var(--color-border));
outline-offset: -1px;
border-radius: 8px;
}
.widget-wrapper--editing[draggable="true"] {
cursor: grab;
}
.widget-wrapper--dragging {
opacity: 0.45;
}
.widget-wrapper--drag-over {
outline: 2px dashed var(--module-accent);
background: color-mix(in srgb, var(--module-accent) 9%, transparent);
}
.widget-edit-controls {
position: absolute;
top: 6px;
left: 6px;
right: 6px;
z-index: 4;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: color-mix(in srgb, var(--color-surface) 96%, transparent);
box-shadow: var(--shadow-xs);
}
.widget-edit-controls__handle,
.widget-edit-controls__hide {
width: 30px;
height: 30px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.widget-edit-controls__handle {
cursor: grab;
}
.widget-edit-controls__hide {
cursor: pointer;
}
.widget-edit-controls__handle svg,
.widget-edit-controls__hide svg {
width: 15px;
height: 15px;
}
.widget-edit-controls__size {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
flex: 1;
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.widget-edit-controls__select {
min-width: 76px;
height: 30px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text-primary);
font-size: var(--text-xs);
}
.dashboard-kpi-grid {
display: grid;
gap: var(--space-3);
+27 -5
View File
@@ -25,8 +25,21 @@ const DEFAULT_DATE_FORMAT = 'mdy';
const VALID_TIME_FORMATS = ['24h', '12h'];
const DEFAULT_TIME_FORMAT = '24h';
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
const VALID_WIDGET_SIZES = ['1x1', '2x1', '2x2', '3x1', '3x2', '4x1', '4x2'];
function defaultWidgetSize(id) {
if (['tasks', 'calendar'].includes(id)) return '2x2';
if (['weather', 'shopping', 'notes'].includes(id)) return '2x1';
return '1x1';
}
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id, order) => ({
id,
visible: true,
order,
size: defaultWidgetSize(id),
})));
// --------------------------------------------------------
// Hilfsfunktionen
@@ -67,15 +80,24 @@ 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) }))
.map((w, order) => ({
id: w.id,
visible: w.visible !== false,
order: Number.isFinite(Number(w.order)) ? Number(w.order) : order,
size: VALID_WIDGET_SIZES.includes(w.size) ? w.size : defaultWidgetSize(w.id),
}))
: [];
// 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 });
if (!presentIds.has(id)) {
valid.push({ id, visible: true, order: valid.length, size: defaultWidgetSize(id) });
}
}
return valid;
return valid
.sort((a, b) => a.order - b.order)
.map((w, order) => ({ ...w, order }));
}
// --------------------------------------------------------