Add dashboard widget customization
This commit is contained in:
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "للأعلى",
|
||||
"customizeMoveDown": "للأسفل",
|
||||
"overdueTasksChip": "{{count}} مهمة متأخرة",
|
||||
"overdueTasksChipPlural": "{{count}} مهام متأخرة"
|
||||
"overdueTasksChipPlural": "{{count}} مهام متأخرة",
|
||||
"customizeManage": "الأدوات",
|
||||
"customizeExit": "إنهاء التخصيص",
|
||||
"customizeDrag": "اسحب الأداة",
|
||||
"customizeSize": "الحجم",
|
||||
"customizeSizeFor": "حجم {{widget}}",
|
||||
"customizeHide": "إخفاء {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "المهام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Πάνω",
|
||||
"customizeMoveDown": "Κάτω",
|
||||
"overdueTasksChip": "{{count}} εκπρόθεσμη εργασία",
|
||||
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες"
|
||||
"overdueTasksChipPlural": "{{count}} εκπρόθεσμες εργασίες",
|
||||
"customizeManage": "Widget",
|
||||
"customizeExit": "Έξοδος από προσαρμογή",
|
||||
"customizeDrag": "Σύρετε widget",
|
||||
"customizeSize": "Μέγεθος",
|
||||
"customizeSizeFor": "Μέγεθος για {{widget}}",
|
||||
"customizeHide": "Απόκρυψη {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Εργασίες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "ऊपर ले जाएं",
|
||||
"customizeMoveDown": "नीचे ले जाएं",
|
||||
"overdueTasksChip": "{{count}} विलंबित कार्य",
|
||||
"overdueTasksChipPlural": "{{count}} विलंबित कार्य"
|
||||
"overdueTasksChipPlural": "{{count}} विलंबित कार्य",
|
||||
"customizeManage": "विजेट",
|
||||
"customizeExit": "अनुकूलन से बाहर निकलें",
|
||||
"customizeDrag": "विजेट खींचें",
|
||||
"customizeSize": "आकार",
|
||||
"customizeSizeFor": "{{widget}} का आकार",
|
||||
"customizeHide": "{{widget}} छिपाएँ"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "कार्य",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "上へ",
|
||||
"customizeMoveDown": "下へ",
|
||||
"overdueTasksChip": "期限超過のタスク {{count}} 件",
|
||||
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件"
|
||||
"overdueTasksChipPlural": "期限超過のタスク {{count}} 件",
|
||||
"customizeManage": "ウィジェット",
|
||||
"customizeExit": "カスタマイズを終了",
|
||||
"customizeDrag": "ウィジェットをドラッグ",
|
||||
"customizeSize": "サイズ",
|
||||
"customizeSizeFor": "{{widget}} のサイズ",
|
||||
"customizeHide": "{{widget}} を非表示"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "タスク",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Вверх",
|
||||
"customizeMoveDown": "Вниз",
|
||||
"overdueTasksChip": "{{count}} просроченная задача",
|
||||
"overdueTasksChipPlural": "{{count}} просроченных задач"
|
||||
"overdueTasksChipPlural": "{{count}} просроченных задач",
|
||||
"customizeManage": "Виджеты",
|
||||
"customizeExit": "Выйти из настройки",
|
||||
"customizeDrag": "Перетащить виджет",
|
||||
"customizeSize": "Размер",
|
||||
"customizeSizeFor": "Размер для {{widget}}",
|
||||
"customizeHide": "Скрыть {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Задачи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "Перемістити вгору",
|
||||
"customizeMoveDown": "Перемістити вниз",
|
||||
"overdueTasksChip": "{{count}} прострочене завдання",
|
||||
"overdueTasksChipPlural": "{{count}} прострочених завдань"
|
||||
"overdueTasksChipPlural": "{{count}} прострочених завдань",
|
||||
"customizeManage": "Віджети",
|
||||
"customizeExit": "Вийти з налаштування",
|
||||
"customizeDrag": "Перетягнути віджет",
|
||||
"customizeSize": "Розмір",
|
||||
"customizeSizeFor": "Розмір для {{widget}}",
|
||||
"customizeHide": "Приховати {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Завдання",
|
||||
|
||||
@@ -112,7 +112,13 @@
|
||||
"customizeMoveUp": "上移",
|
||||
"customizeMoveDown": "下移",
|
||||
"overdueTasksChip": "{{count}} 个逾期任务",
|
||||
"overdueTasksChipPlural": "{{count}} 个逾期任务"
|
||||
"overdueTasksChipPlural": "{{count}} 个逾期任务",
|
||||
"customizeManage": "小组件",
|
||||
"customizeExit": "退出自定义",
|
||||
"customizeDrag": "拖动小组件",
|
||||
"customizeSize": "大小",
|
||||
"customizeSizeFor": "{{widget}} 的大小",
|
||||
"customizeHide": "隐藏 {{widget}}"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "任务",
|
||||
|
||||
+234
-28
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user