chore: release v0.20.21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.20.21] - 2026-04-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dashboard: eliminated double-render flicker — initial paint uses skeleton widgets and a stat-less greeting; real widgets replace skeletons in-place without resetting `container.innerHTML`
|
||||||
|
- Dashboard: weather widget now derives temperature unit symbol (°C / °F / K) from the `units` field returned by the weather API instead of always showing °C
|
||||||
|
- Dark mode: removed duplicate `@media (prefers-color-scheme: dark)` block from `tokens.css`; system-preference detection moved to a `matchMedia` listener in `index.html` for flash-free sync
|
||||||
|
- Tasks: view-toggle (list / Kanban) fades out at 40% opacity during re-render and fades back in, giving visible feedback of the switch
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tasks: inline `style="width/height"` on all Lucide icon instances replaced with utility CSS classes (`icon-xs` … `icon-2xl`, `icon-11`) defined in `layout.css`
|
||||||
|
- Tasks: edit-button inline size overrides removed; replaced with new `.btn--icon-sm` utility class
|
||||||
|
- Tasks: `textarea` `resize: vertical` and select `min-height: 44px` moved from inline styles to `layout.css`
|
||||||
|
- Dashboard: `chipIcon` inline style variable eliminated; chip icons now use `class="icon-sm"`
|
||||||
|
- Dashboard: settings, refresh, chevron, and other action icons converted from inline styles to CSS classes
|
||||||
|
- Weather API: server now forwards the configured `units` value in the response payload so the frontend can render the correct unit symbol
|
||||||
|
|
||||||
## [0.20.20] - 2026-04-20
|
## [0.20.20] - 2026-04-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.20",
|
"version": "0.20.21",
|
||||||
"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",
|
||||||
|
|||||||
+11
-3
@@ -40,11 +40,19 @@
|
|||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
<link rel="stylesheet" href="/styles/reminders.css" />
|
<link rel="stylesheet" href="/styles/reminders.css" />
|
||||||
|
|
||||||
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention) -->
|
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention + System-Sync).
|
||||||
|
Setzt data-theme einmalig beim Laden, damit tokens.css nur [data-theme="dark"]
|
||||||
|
braucht — kein duplizierter @media-Block mehr nötig. -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var t = localStorage.getItem('oikos-theme');
|
var stored = localStorage.getItem('oikos-theme');
|
||||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
document.documentElement.setAttribute('data-theme', stored || (prefersDark ? 'dark' : 'light'));
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
if (!localStorage.getItem('oikos-theme')) {
|
||||||
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+20
-34
@@ -149,21 +149,20 @@ function skeletonWidget(lines = 3) {
|
|||||||
function renderGreeting(user, stats = {}) {
|
function renderGreeting(user, stats = {}) {
|
||||||
const { urgentCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
|
const { urgentCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
|
||||||
|
|
||||||
const chipIcon = 'width:12px;height:12px;flex-shrink:0;';
|
|
||||||
const statChips = [];
|
const statChips = [];
|
||||||
if (urgentCount > 0)
|
if (urgentCount > 0)
|
||||||
statChips.push(`<span class="greeting-chip greeting-chip--warn">
|
statChips.push(`<span class="greeting-chip greeting-chip--warn">
|
||||||
<i data-lucide="alert-circle" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="alert-circle" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||||
${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })}
|
${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })}
|
||||||
</span>`);
|
</span>`);
|
||||||
if (todayEventCount > 0)
|
if (todayEventCount > 0)
|
||||||
statChips.push(`<span class="greeting-chip">
|
statChips.push(`<span class="greeting-chip">
|
||||||
<i data-lucide="calendar" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="calendar" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||||
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
|
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
|
||||||
</span>`);
|
</span>`);
|
||||||
if (todayMealTitle)
|
if (todayMealTitle)
|
||||||
statChips.push(`<span class="greeting-chip">
|
statChips.push(`<span class="greeting-chip">
|
||||||
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="utensils" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||||
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
|
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
|
||||||
</span>`);
|
</span>`);
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ function renderGreeting(user, stats = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<button class="widget-customize-btn" id="dashboard-customize-btn"
|
<button class="widget-customize-btn" id="dashboard-customize-btn"
|
||||||
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
||||||
<i data-lucide="settings-2" style="width:16px;height:16px" aria-hidden="true"></i>
|
<i data-lucide="settings-2" class="icon-base" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +357,8 @@ const WEATHER_ICON_BASE = '/api/v1/weather/icon/';
|
|||||||
function renderWeatherWidget(weather) {
|
function renderWeatherWidget(weather) {
|
||||||
if (!weather) return '';
|
if (!weather) return '';
|
||||||
|
|
||||||
const { city, current, forecast } = weather;
|
const { city, current, forecast, units } = weather;
|
||||||
|
const unitSymbol = units === 'imperial' ? '°F' : units === 'standard' ? 'K' : '°C';
|
||||||
|
|
||||||
const forecastHtml = forecast.map((d, i) => {
|
const forecastHtml = forecast.map((d, i) => {
|
||||||
const date = new Date(d.date + 'T12:00:00');
|
const date = new Date(d.date + 'T12:00:00');
|
||||||
@@ -379,12 +379,12 @@ function renderWeatherWidget(weather) {
|
|||||||
return `
|
return `
|
||||||
<div class="widget weather-widget" id="weather-widget">
|
<div class="widget weather-widget" id="weather-widget">
|
||||||
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="${t('dashboard.weatherRefresh')}" title="${t('dashboard.weatherRefreshTitle')}">
|
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="${t('dashboard.weatherRefresh')}" title="${t('dashboard.weatherRefreshTitle')}">
|
||||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="refresh-cw" class="icon-md" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="weather-widget__inner">
|
<div class="weather-widget__inner">
|
||||||
<div class="weather-widget__main">
|
<div class="weather-widget__main">
|
||||||
<div class="weather-widget__left">
|
<div class="weather-widget__left">
|
||||||
<div class="weather-widget__temp">${esc(current.temp)}°C</div>
|
<div class="weather-widget__temp">${esc(current.temp)}${unitSymbol}</div>
|
||||||
<div class="weather-widget__desc">${esc(current.desc)}</div>
|
<div class="weather-widget__desc">${esc(current.desc)}</div>
|
||||||
<div class="weather-widget__city">${esc(city)}</div>
|
<div class="weather-widget__city">${esc(city)}</div>
|
||||||
<div class="weather-widget__meta">
|
<div class="weather-widget__meta">
|
||||||
@@ -510,11 +510,11 @@ function openCustomizeModal(currentConfig, onSave) {
|
|||||||
<div class="customize-row__actions">
|
<div class="customize-row__actions">
|
||||||
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
|
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
|
||||||
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
|
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
|
||||||
<i data-lucide="chevron-up" style="width:14px;height:14px" aria-hidden="true"></i>
|
<i data-lucide="chevron-up" class="icon-md" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="customize-row__btn" data-move="down" data-id="${w.id}"
|
<button class="customize-row__btn" data-move="down" data-id="${w.id}"
|
||||||
${isLast ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveDown')}">
|
${isLast ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveDown')}">
|
||||||
<i data-lucide="chevron-down" style="width:14px;height:14px" aria-hidden="true"></i>
|
<i data-lucide="chevron-down" class="icon-md" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -624,15 +624,9 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
|
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard__grid">
|
||||||
<div class="widget-greeting" style="grid-column:1/-1">
|
${renderGreeting(user, {})}
|
||||||
<div class="widget-greeting__inner">
|
|
||||||
<div class="widget-greeting__content">
|
|
||||||
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
|
||||||
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${skeletonWidget(3)}
|
${skeletonWidget(3)}
|
||||||
${skeletonWidget(3)}
|
${skeletonWidget(3)}
|
||||||
${skeletonWidget(2)}
|
${skeletonWidget(2)}
|
||||||
@@ -671,9 +665,9 @@ export async function render(container, { user }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function rebuildGrid(cfg) {
|
function rebuildGrid(cfg) {
|
||||||
const grid = container.querySelector('.dashboard__grid');
|
const grid = container.querySelector('.dashboard__grid');
|
||||||
const greeting = grid?.querySelector('.widget-greeting');
|
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
const greeting = grid.querySelector('.widget-greeting');
|
||||||
grid.replaceChildren(...(greeting ? [greeting] : []));
|
grid.replaceChildren(...(greeting ? [greeting] : []));
|
||||||
grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
|
grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
|
||||||
wireLinks(container);
|
wireLinks(container);
|
||||||
@@ -681,20 +675,14 @@ export async function render(container, { user }) {
|
|||||||
wireWeatherRefresh(container);
|
wireWeatherRefresh(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
// Greeting in-place aktualisieren (Stats-Chips hinzufügen), kein Gesamt-Reset
|
||||||
<div class="dashboard">
|
const greetingEl = container.querySelector('.widget-greeting');
|
||||||
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats);
|
||||||
<div class="dashboard__grid">
|
|
||||||
${renderGreeting(user, stats)}
|
// Skeletons durch echte Widgets ersetzen
|
||||||
${renderWidgets(widgetConfig, data, weather)}
|
rebuildGrid(widgetConfig);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${renderFab()}
|
|
||||||
`;
|
|
||||||
|
|
||||||
wireLinks(container);
|
|
||||||
initFab(container, _fabController.signal);
|
initFab(container, _fabController.signal);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
|
||||||
|
|
||||||
container.querySelector('#dashboard-customize-btn')?.addEventListener(
|
container.querySelector('#dashboard-customize-btn')?.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
@@ -705,8 +693,6 @@ export async function render(container, { user }) {
|
|||||||
{ signal: _fabController.signal },
|
{ signal: _fabController.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
wireWeatherRefresh(container);
|
|
||||||
|
|
||||||
// 30-Minuten Auto-Refresh für Wetter
|
// 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) {
|
||||||
|
|||||||
+25
-19
@@ -123,7 +123,7 @@ function renderDueDate(dateStr) {
|
|||||||
const d = formatDueDate(dateStr);
|
const d = formatDueDate(dateStr);
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
return `<span class="due-date ${d.cls}">
|
return `<span class="due-date ${d.cls}">
|
||||||
<i data-lucide="clock" style="width:11px;height:11px" aria-hidden="true"></i> ${d.label}
|
<i data-lucide="clock" class="icon-11" aria-hidden="true"></i> ${d.label}
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +132,11 @@ function renderSwipeRow(task, innerHtml) {
|
|||||||
return `
|
return `
|
||||||
<div class="swipe-row" data-swipe-id="${task.id}" data-swipe-status="${task.status}">
|
<div class="swipe-row" data-swipe-id="${task.id}" data-swipe-status="${task.status}">
|
||||||
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
||||||
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" class="icon-xl" aria-hidden="true"></i>
|
||||||
<span>${isDone ? t('tasks.swipeOpen') : t('tasks.swipeDone')}</span>
|
<span>${isDone ? t('tasks.swipeOpen') : t('tasks.swipeDone')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="swipe-reveal swipe-reveal--edit" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--edit" aria-hidden="true">
|
||||||
<i data-lucide="pencil" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="pencil" class="icon-xl" aria-hidden="true"></i>
|
||||||
<span>${t('tasks.swipeEdit')}</span>
|
<span>${t('tasks.swipeEdit')}</span>
|
||||||
</div>
|
</div>
|
||||||
${innerHtml}
|
${innerHtml}
|
||||||
@@ -179,7 +179,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
<div class="task-card__meta">
|
<div class="task-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
${renderDueDate(task.due_date)}
|
${renderDueDate(task.due_date)}
|
||||||
${task.is_recurring ? `<span class="due-date" aria-label="${t('tasks.recurring')}"><i data-lucide="repeat" style="width:12px;height:12px" aria-hidden="true"></i></span>` : ''}
|
${task.is_recurring ? `<span class="due-date" aria-label="${t('tasks.recurring')}"><i data-lucide="repeat" class="icon-sm" aria-hidden="true"></i></span>` : ''}
|
||||||
${task.category !== 'Sonstiges' ? `<span class="due-date">${CATEGORY_LABELS()[task.category] ?? task.category}</span>` : ''}
|
${task.category !== 'Sonstiges' ? `<span class="due-date">${CATEGORY_LABELS()[task.category] ?? task.category}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,9 +190,9 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
${esc(initials(task.assigned_name ?? ''))}
|
${esc(initials(task.assigned_name ?? ''))}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
|
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="edit-task" data-id="${task.id}"
|
||||||
aria-label="${t('tasks.editButton')}" style="min-height:unset;width:36px;height:36px">
|
aria-label="${t('tasks.editButton')}">
|
||||||
<i data-lucide="pencil" style="width:16px;height:16px" aria-hidden="true"></i>
|
<i data-lucide="pencil" class="icon-base" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,19 +284,19 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
|||||||
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
|
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
|
||||||
<textarea class="input" id="task-description" name="description"
|
<textarea class="input" id="task-description" name="description"
|
||||||
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
|
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
|
||||||
style="resize:vertical">${esc(task?.description)}</textarea>
|
>${esc(task?.description)}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-grid modal-grid--2">
|
<div class="modal-grid modal-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-priority">${t('tasks.priorityLabel')}</label>
|
<label class="label" for="task-priority">${t('tasks.priorityLabel')}</label>
|
||||||
<select class="input" id="task-priority" name="priority" style="min-height:44px">
|
<select class="input" id="task-priority" name="priority">
|
||||||
${priorityOptions}
|
${priorityOptions}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-category">${t('tasks.categoryLabel')}</label>
|
<label class="label" for="task-category">${t('tasks.categoryLabel')}</label>
|
||||||
<select class="input" id="task-category" name="category" style="min-height:44px">
|
<select class="input" id="task-category" name="category">
|
||||||
${categoryOptions}
|
${categoryOptions}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +317,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
|||||||
|
|
||||||
<div class="form-group" style="margin-top:var(--space-4)">
|
<div class="form-group" style="margin-top:var(--space-4)">
|
||||||
<label class="label" for="task-assigned">${t('tasks.assignedLabel')}</label>
|
<label class="label" for="task-assigned">${t('tasks.assignedLabel')}</label>
|
||||||
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
|
<select class="input" id="task-assigned" name="assigned_to">
|
||||||
<option value="">${t('tasks.assignedNobody')}</option>
|
<option value="">${t('tasks.assignedNobody')}</option>
|
||||||
${userOptions}
|
${userOptions}
|
||||||
</select>
|
</select>
|
||||||
@@ -326,7 +326,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
|||||||
${isEdit ? `
|
${isEdit ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-status">${t('tasks.statusLabel')}</label>
|
<label class="label" for="task-status">${t('tasks.statusLabel')}</label>
|
||||||
<select class="input" id="task-status" name="status" style="min-height:44px">
|
<select class="input" id="task-status" name="status">
|
||||||
${STATUSES().map((s) =>
|
${STATUSES().map((s) =>
|
||||||
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
|
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
@@ -597,7 +597,7 @@ function renderKanbanCard(task) {
|
|||||||
<div class="kanban-card__title">${esc(task.title)}</div>
|
<div class="kanban-card__title">${esc(task.title)}</div>
|
||||||
<div class="kanban-card__meta">
|
<div class="kanban-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" class="icon-xs" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="kanban-card__footer">
|
<div class="kanban-card__footer">
|
||||||
${task.assigned_color ? `
|
${task.assigned_color ? `
|
||||||
@@ -1034,10 +1034,16 @@ function wireViewToggle(container) {
|
|||||||
toggle.querySelectorAll('[data-view]').forEach((b) =>
|
toggle.querySelectorAll('[data-view]').forEach((b) =>
|
||||||
b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode)
|
b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode)
|
||||||
);
|
);
|
||||||
// Gruppierungs-Toggle nur in Listenansicht sinnvoll
|
|
||||||
const groupToggle = container.querySelector('#group-mode-toggle');
|
const groupToggle = container.querySelector('#group-mode-toggle');
|
||||||
if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none';
|
if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none';
|
||||||
renderTaskList(container);
|
// Skeleton-Flash: einen Frame Render-Feedback geben, dann Ansicht aufbauen
|
||||||
|
const listEl = container.querySelector('#task-list');
|
||||||
|
if (listEl) listEl.style.opacity = '0.4';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderTaskList(container);
|
||||||
|
const el = container.querySelector('#task-list');
|
||||||
|
if (el) { el.style.transition = 'opacity 0.15s'; el.style.opacity = ''; }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1143,11 +1149,11 @@ export async function render(container, { user }) {
|
|||||||
<div class="group-toggle" id="view-toggle">
|
<div class="group-toggle" id="view-toggle">
|
||||||
<button class="group-toggle__btn ${isKanban ? '' : 'group-toggle__btn--active'}" data-view="list"
|
<button class="group-toggle__btn ${isKanban ? '' : 'group-toggle__btn--active'}" data-view="list"
|
||||||
title="${t('tasks.listView')}" aria-label="${t('tasks.listView')}">
|
title="${t('tasks.listView')}" aria-label="${t('tasks.listView')}">
|
||||||
<i data-lucide="list" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
<i data-lucide="list" class="icon-md" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="group-toggle__btn ${isKanban ? 'group-toggle__btn--active' : ''}" data-view="kanban"
|
<button class="group-toggle__btn ${isKanban ? 'group-toggle__btn--active' : ''}" data-view="kanban"
|
||||||
title="${t('tasks.kanbanView')}" aria-label="${t('tasks.kanbanView')}">
|
title="${t('tasks.kanbanView')}" aria-label="${t('tasks.kanbanView')}">
|
||||||
<i data-lucide="columns" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
<i data-lucide="columns" class="icon-md" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-toggle" id="group-mode-toggle" ${isKanban ? 'style="display:none"' : ''}>
|
<div class="group-toggle" id="group-mode-toggle" ${isKanban ? 'style="display:none"' : ''}>
|
||||||
@@ -1155,7 +1161,7 @@ export async function render(container, { user }) {
|
|||||||
<button class="group-toggle__btn" data-mode="due">${t('tasks.dueDateLabel')}</button>
|
<button class="group-toggle__btn" data-mode="due">${t('tasks.dueDateLabel')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
|
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
|
||||||
<i data-lucide="plus" style="width:18px;height:18px" aria-hidden="true"></i> ${t('tasks.newTask')}
|
<i data-lucide="plus" class="icon-lg" aria-hidden="true"></i> ${t('tasks.newTask')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1171,7 +1177,7 @@ export async function render(container, { user }) {
|
|||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<button class="page-fab" id="fab-new-task" aria-label="${t('tasks.newTask')}">
|
<button class="page-fab" id="fab-new-task" aria-label="${t('tasks.newTask')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" class="icon-2xl" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1779,6 +1779,36 @@
|
|||||||
border-radius: 0 0 var(--radius-sm) 0;
|
border-radius: 0 0 var(--radius-sm) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Icon-Größen-Utilities
|
||||||
|
* Größen für Lucide-Icons (<i data-lucide>). Klassen werden
|
||||||
|
* vom Lucide-Renderer auf das erzeugte <svg> übertragen.
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.icon-xs { width: 10px; height: 10px; flex-shrink: 0; }
|
||||||
|
.icon-11 { width: 11px; height: 11px; flex-shrink: 0; }
|
||||||
|
.icon-sm { width: 12px; height: 12px; flex-shrink: 0; }
|
||||||
|
.icon-md { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
.icon-base { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
|
.icon-lg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
.icon-xl { width: 22px; height: 22px; flex-shrink: 0; }
|
||||||
|
.icon-2xl { width: 24px; height: 24px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Icons innerhalb von Buttons sollen keine Pointer-Events fangen */
|
||||||
|
button i[data-lucide],
|
||||||
|
button svg { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Kompakter Icon-Button (36×36) für Icons in engen Listenkontexten */
|
||||||
|
.btn--icon-sm {
|
||||||
|
padding: var(--space-1);
|
||||||
|
min-height: unset;
|
||||||
|
min-width: unset;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea: vertikale Größenänderung ist nutzbar */
|
||||||
|
textarea.input { resize: vertical; }
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Windows High Contrast / Forced Colors
|
* Windows High Contrast / Forced Colors
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -415,15 +415,11 @@
|
|||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Dark Mode — private Tokens überschreiben, öffentliche API bleibt stabil.
|
* Dark Mode — private Tokens überschreiben, öffentliche API bleibt stabil.
|
||||||
*
|
*
|
||||||
* Beide Selektoren überschreiben nur --_private Tokens. Die öffentlichen
|
* data-theme="dark" wird durch das Head-Script in index.html gesetzt —
|
||||||
* --color-* / --module-* / --glass-* Tokens müssen nie angefasst werden.
|
* sowohl für manuelle Overrides als auch für System-Präferenz (via
|
||||||
*
|
* matchMedia-Listener). Ein einziger Selektor reicht, kein @media-Duplikat.
|
||||||
* (1) System-Preference — greift, wenn kein data-theme gesetzt ist.
|
|
||||||
* (2) Manueller Override — greift, wenn JS data-theme="dark" setzt
|
|
||||||
* (auch bei System in Light-Mode).
|
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@media (prefers-color-scheme: dark) {
|
[data-theme="dark"] {
|
||||||
:root:not([data-theme="light"]) {
|
|
||||||
/* Neutral-Skala invertiert (warm-dunkel) */
|
/* Neutral-Skala invertiert (warm-dunkel) */
|
||||||
--_neutral-50: #1A1A18;
|
--_neutral-50: #1A1A18;
|
||||||
--_neutral-100: #222220;
|
--_neutral-100: #222220;
|
||||||
@@ -519,95 +515,6 @@
|
|||||||
--_glass-bg-input: rgba(34, 34, 32, 0.45);
|
--_glass-bg-input: rgba(34, 34, 32, 0.45);
|
||||||
--_glass-bg-toolbar: rgba(40, 40, 38, 0.55);
|
--_glass-bg-toolbar: rgba(40, 40, 38, 0.55);
|
||||||
--_glass-tint-strength: 8%;
|
--_glass-tint-strength: 8%;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Manueller Dark-Mode-Override: data-theme="dark" auf <html> (auch bei System in Light-Mode) */
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--_neutral-50: #1A1A18;
|
|
||||||
--_neutral-100: #222220;
|
|
||||||
--_neutral-150: #2A2A28;
|
|
||||||
--_neutral-200: #333331;
|
|
||||||
--_neutral-250: #3D3D3A;
|
|
||||||
--_neutral-300: #48484A;
|
|
||||||
--_neutral-400: #636360;
|
|
||||||
--_neutral-500: #8E8D89;
|
|
||||||
--_neutral-600: #AEADB0;
|
|
||||||
--_neutral-700: #C8C7C3;
|
|
||||||
--_neutral-800: #E2E1DC;
|
|
||||||
--_neutral-900: #F5F4F1;
|
|
||||||
--_neutral-950: #FAFAF8;
|
|
||||||
|
|
||||||
--_color-surface: #2A2A28;
|
|
||||||
--_color-surface-3: #333331;
|
|
||||||
|
|
||||||
--_sidebar-bg: #1A1A18;
|
|
||||||
--_sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
|
||||||
--_sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
|
||||||
|
|
||||||
--_color-accent: #818CF8;
|
|
||||||
--_color-accent-hover: #6366F1;
|
|
||||||
--_color-accent-active: #4F46E5;
|
|
||||||
--_color-accent-light: #2E2D5B;
|
|
||||||
--_color-accent-subtle: #252255;
|
|
||||||
--_color-btn-primary: #6366F1;
|
|
||||||
--_color-btn-primary-hover: #4F46E5;
|
|
||||||
--_color-accent-secondary: #A78BFA;
|
|
||||||
|
|
||||||
--_color-success: #4ADE80;
|
|
||||||
--_color-warning: #F59E0B;
|
|
||||||
--_color-danger: #FCA5A5;
|
|
||||||
--_color-text-tertiary: #A3A3A0;
|
|
||||||
--_color-success-light: #1A3325;
|
|
||||||
--_color-warning-light: #332400;
|
|
||||||
--_color-danger-light: #3D1C1A;
|
|
||||||
--_color-info-light: #1A2D40;
|
|
||||||
|
|
||||||
--_module-dashboard: #818CF8;
|
|
||||||
--_module-tasks: #4ADE80;
|
|
||||||
--_module-calendar: #A78BFA;
|
|
||||||
--_module-meals: #FB923C;
|
|
||||||
--_module-shopping: #F472B6;
|
|
||||||
--_module-notes: #FCD34D;
|
|
||||||
--_module-contacts: #60A5FA;
|
|
||||||
--_module-budget: #2DD4BF;
|
|
||||||
--_module-settings: #94A3B8;
|
|
||||||
|
|
||||||
--_meal-breakfast: #F59E0B;
|
|
||||||
--_meal-breakfast-light: #332400;
|
|
||||||
--_meal-lunch-light: #1A3325;
|
|
||||||
--_meal-dinner: #818CF8;
|
|
||||||
--_meal-dinner-light: #2E2D5B;
|
|
||||||
--_meal-snack-light: #3D2010;
|
|
||||||
|
|
||||||
--_color-priority-none-bg: rgba(142, 141, 137, 0.12);
|
|
||||||
--_color-priority-low-bg: rgba(142, 141, 137, 0.18);
|
|
||||||
--_color-priority-medium-bg: rgba(230, 147, 10, 0.18);
|
|
||||||
--_color-priority-high-bg: rgba(212, 81, 30, 0.18);
|
|
||||||
--_color-priority-urgent-bg: rgba(229, 83, 75, 0.18);
|
|
||||||
|
|
||||||
--_color-overlay: rgba(0, 0, 0, 0.6);
|
|
||||||
--_color-overlay-light: rgba(0, 0, 0, 0.35);
|
|
||||||
|
|
||||||
--_shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
|
|
||||||
--_shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
|
||||||
--_shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
||||||
|
|
||||||
--_glass-bg: rgba(40, 40, 38, 0.75);
|
|
||||||
--_glass-bg-hover: rgba(50, 50, 48, 0.82);
|
|
||||||
--_glass-bg-elevated: rgba(58, 58, 55, 0.90);
|
|
||||||
--_glass-border: rgba(255, 255, 255, 0.12);
|
|
||||||
--_glass-border-subtle: rgba(255, 255, 255, 0.07);
|
|
||||||
--_glass-highlight: rgba(255, 255, 255, 0.10);
|
|
||||||
--_glass-highlight-subtle: rgba(255, 255, 255, 0.06);
|
|
||||||
--_glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.30), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
|
||||||
--_glass-shadow-md: 0 4px 20px rgba(0, 0, 0, 0.40), 0 0 0 1px rgba(255, 255, 255, 0.07);
|
|
||||||
--_glass-shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
||||||
--_glass-bg-card: rgba(38, 38, 36, 0.50);
|
|
||||||
--_glass-bg-card-hover: rgba(48, 48, 46, 0.62);
|
|
||||||
--_glass-bg-input: rgba(34, 34, 32, 0.45);
|
|
||||||
--_glass-bg-toolbar: rgba(40, 40, 38, 0.55);
|
|
||||||
--_glass-tint-strength: 8%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
city: currentJson.name,
|
city: currentJson.name,
|
||||||
|
units,
|
||||||
current: {
|
current: {
|
||||||
temp: Math.round(currentJson.main.temp),
|
temp: Math.round(currentJson.main.temp),
|
||||||
feels_like: Math.round(currentJson.main.feels_like),
|
feels_like: Math.round(currentJson.main.feels_like),
|
||||||
|
|||||||
Reference in New Issue
Block a user