chore: release v0.20.24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-20 10:05:12 +02:00
parent aae895d704
commit e48d249fbe
10 changed files with 606 additions and 115 deletions
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.20.24] - 2026-04-20
### Added
- Tasks: subtle green edge indicator on touch devices hints at the swipe-left gesture without requiring an actual swipe (hidden during active swipe)
- Global search: new search overlay accessible from the "More" sheet — searches tasks, calendar events, and notes simultaneously; results link directly to the relevant record
- Navigation: bottom bar now shows 4 primary items plus a "More" button that opens a slide-up sheet with remaining sections and the search entry point; replaces the old 2-page swipe approach
### Changed
- Server: `VALID_CATEGORIES` in tasks route updated to English keys to match the v9 DB migration
## [0.20.23] - 2026-04-20 ## [0.20.23] - 2026-04-20
### Added ### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.20.23", "version": "0.20.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.20.23", "version": "0.20.24",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.20.23", "version": "0.20.24",
"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",
+8 -1
View File
@@ -42,7 +42,14 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"main": "Hauptnavigation", "main": "Hauptnavigation",
"navigation": "Navigation", "navigation": "Navigation",
"quickActions": "Schnellaktionen" "quickActions": "Schnellaktionen",
"more": "Mehr"
},
"search": {
"title": "Suche",
"placeholder": "Suchen…",
"noResults": "Keine Ergebnisse gefunden.",
"open": "Suche öffnen"
}, },
"dashboard": { "dashboard": {
"title": "Übersicht", "title": "Übersicht",
+301 -75
View File
@@ -4,7 +4,7 @@
* Abhängigkeiten: api.js * Abhängigkeiten: api.js
*/ */
import { auth } from '/api.js'; import { api, auth } from '/api.js';
import { initI18n, getLocale, t } from '/i18n.js'; import { initI18n, getLocale, t } from '/i18n.js';
import { init as initReminders, stop as stopReminders } from '/reminders.js'; import { init as initReminders, stop as stopReminders } from '/reminders.js';
@@ -126,6 +126,8 @@ let _pendingLoginRedirect = false;
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping', const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping',
'/notes', '/contacts', '/budget', '/settings']; '/notes', '/contacts', '/budget', '/settings'];
const PRIMARY_NAV = 4;
function getDirection(fromPath, toPath) { function getDirection(fromPath, toPath) {
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/'); const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
const toIdx = ROUTE_ORDER.indexOf(toPath); const toIdx = ROUTE_ORDER.indexOf(toPath);
@@ -285,35 +287,115 @@ async function renderPage(route, previousPath = null) {
* App-Shell mit Navigation einmalig aufbauen (nach erstem Login). * App-Shell mit Navigation einmalig aufbauen (nach erstem Login).
*/ */
function renderAppShell(container) { function renderAppShell(container) {
container.innerHTML = ` const skipLink = document.createElement('a');
<a href="#main-content" class="sr-only">${t('common.skipToContent')}</a> skipLink.href = '#main-content';
<nav class="nav-sidebar" aria-label="${t('nav.main')}"> skipLink.className = 'sr-only';
<div class="nav-sidebar__logo"><span>Oikos</span></div> skipLink.textContent = t('common.skipToContent');
<div class="nav-sidebar__items" role="list">
${navItems().map(navItemHtml).join('')}
</div>
</nav>
<main class="app-content" id="main-content" aria-live="polite"> const sidebar = document.createElement('nav');
</main> sidebar.className = 'nav-sidebar';
sidebar.setAttribute('aria-label', t('nav.main'));
const sidebarLogo = document.createElement('div');
sidebarLogo.className = 'nav-sidebar__logo';
const sidebarLogoSpan = document.createElement('span');
sidebarLogoSpan.textContent = 'Oikos';
sidebarLogo.appendChild(sidebarLogoSpan);
const sidebarItems = document.createElement('div');
sidebarItems.className = 'nav-sidebar__items';
sidebarItems.setAttribute('role', 'list');
navItems().forEach((item) => sidebarItems.appendChild(navItemEl(item)));
sidebar.appendChild(sidebarLogo);
sidebar.appendChild(sidebarItems);
<nav class="nav-bottom" aria-label="${t('nav.navigation')}"> const main = document.createElement('main');
<div class="nav-bottom__dots" aria-hidden="true"> main.className = 'app-content';
<span class="nav-bottom__dot nav-bottom__dot--active"></span> main.id = 'main-content';
<span class="nav-bottom__dot"></span> main.setAttribute('aria-live', 'polite');
</div>
<div class="nav-bottom__scroll">
<div class="nav-bottom__page" role="list">
${navItems().slice(0, 5).map(navItemHtml).join('')}
</div>
<div class="nav-bottom__page" role="list">
${navItems().slice(5).map(navItemHtml).join('')}
</div>
</div>
</nav>
<div class="toast-container" id="toast-container" aria-live="assertive"></div> const bottomNav = document.createElement('nav');
`; bottomNav.className = 'nav-bottom';
bottomNav.setAttribute('aria-label', t('nav.navigation'));
const bottomItems = document.createElement('div');
bottomItems.className = 'nav-bottom__items';
navItems().slice(0, PRIMARY_NAV).forEach((item) => bottomItems.appendChild(navItemEl(item)));
const moreBtn = document.createElement('button');
moreBtn.className = 'nav-item nav-item--more';
moreBtn.id = 'more-btn';
moreBtn.setAttribute('aria-label', t('nav.more'));
moreBtn.setAttribute('aria-expanded', 'false');
const moreBtnIcon = document.createElement('i');
moreBtnIcon.dataset.lucide = 'grid-2x2';
moreBtnIcon.className = 'nav-item__icon';
moreBtnIcon.setAttribute('aria-hidden', 'true');
const moreBtnLabel = document.createElement('span');
moreBtnLabel.className = 'nav-item__label';
moreBtnLabel.textContent = t('nav.more');
moreBtn.appendChild(moreBtnIcon);
moreBtn.appendChild(moreBtnLabel);
bottomItems.appendChild(moreBtn);
bottomNav.appendChild(bottomItems);
const backdrop = document.createElement('div');
backdrop.className = 'more-backdrop';
backdrop.id = 'more-backdrop';
backdrop.setAttribute('aria-hidden', 'true');
const moreSheet = document.createElement('div');
moreSheet.className = 'more-sheet';
moreSheet.id = 'more-sheet';
moreSheet.setAttribute('role', 'dialog');
moreSheet.setAttribute('aria-label', t('nav.more'));
moreSheet.setAttribute('aria-hidden', 'true');
const searchBtn = document.createElement('button');
searchBtn.className = 'more-item';
searchBtn.id = 'search-btn';
const searchIcon = document.createElement('i');
searchIcon.dataset.lucide = 'search';
searchIcon.className = 'more-item__icon';
searchIcon.setAttribute('aria-hidden', 'true');
const searchLabel = document.createElement('span');
searchLabel.className = 'more-item__label';
searchLabel.textContent = t('search.title');
searchBtn.appendChild(searchIcon);
searchBtn.appendChild(searchLabel);
moreSheet.appendChild(searchBtn);
navItems().slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item)));
const searchOverlay = document.createElement('div');
searchOverlay.className = 'search-overlay';
searchOverlay.id = 'search-overlay';
searchOverlay.setAttribute('aria-hidden', 'true');
const searchHeader = document.createElement('div');
searchHeader.className = 'search-overlay__header';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.className = 'search-overlay__input';
searchInput.id = 'search-input';
searchInput.placeholder = t('search.placeholder');
searchInput.setAttribute('aria-label', t('search.title'));
const searchClose = document.createElement('button');
searchClose.className = 'search-overlay__close';
searchClose.id = 'search-close';
searchClose.setAttribute('aria-label', t('common.close'));
const closeIcon = document.createElement('i');
closeIcon.dataset.lucide = 'x';
closeIcon.className = 'search-overlay__close-icon';
closeIcon.setAttribute('aria-hidden', 'true');
searchClose.appendChild(closeIcon);
searchHeader.appendChild(searchInput);
searchHeader.appendChild(searchClose);
const searchResults = document.createElement('div');
searchResults.className = 'search-overlay__results';
searchResults.id = 'search-results';
searchOverlay.appendChild(searchHeader);
searchOverlay.appendChild(searchResults);
const toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
toastContainer.id = 'toast-container';
toastContainer.setAttribute('aria-live', 'assertive');
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
// Klick-Handler für alle Nav-Links // Klick-Handler für alle Nav-Links
container.querySelectorAll('[data-route]').forEach((el) => { container.querySelectorAll('[data-route]').forEach((el) => {
@@ -323,11 +405,9 @@ function renderAppShell(container) {
}); });
}); });
// Bottom-Nav: Scroll-Snap + Dot-Indikator initMoreSheet(container);
initBottomNavSwipe(container);
// Bottom-Nav: Auto-Hide beim Runterscrollen (Mobile)
initNavHideOnScroll(container); initNavHideOnScroll(container);
initSearch(container);
} }
/** /**
@@ -357,30 +437,138 @@ function initNavHideOnScroll(container) {
} }
/** /**
* Initialisiert Swipe-Gesten und Dot-Indikator für die mobile Bottom-Navigation. * Öffnet/schließt das More-Sheet und die Backdrop.
*/ */
function initBottomNavSwipe(container) { function initMoreSheet(container) {
const scroll = container.querySelector('.nav-bottom__scroll'); const moreBtn = container.querySelector('#more-btn');
const dots = container.querySelectorAll('.nav-bottom__dot'); const backdrop = container.querySelector('#more-backdrop');
if (!scroll || !dots.length) return; const sheet = container.querySelector('#more-sheet');
if (!moreBtn || !backdrop || !sheet) return;
// Scroll-Event: Dot-Indikator aktualisieren function openSheet() {
scroll.addEventListener('scroll', () => { sheet.setAttribute('aria-hidden', 'false');
const page = Math.round(scroll.scrollLeft / scroll.offsetWidth); backdrop.classList.add('more-backdrop--visible');
dots.forEach((d, i) => d.classList.toggle('nav-bottom__dot--active', i === page)); moreBtn.setAttribute('aria-expanded', 'true');
}, { passive: true }); if (window.lucide) window.lucide.createIcons();
}
function closeSheet() {
sheet.setAttribute('aria-hidden', 'true');
backdrop.classList.remove('more-backdrop--visible');
moreBtn.setAttribute('aria-expanded', 'false');
}
moreBtn.addEventListener('click', () => {
const isOpen = sheet.getAttribute('aria-hidden') === 'false';
isOpen ? closeSheet() : openSheet();
});
backdrop.addEventListener('click', closeSheet);
sheet.querySelectorAll('[data-route]').forEach((el) => {
el.addEventListener('click', () => closeSheet());
});
window._closeMoreSheet = closeSheet;
} }
/** /**
* Scrollt die Bottom-Nav zur richtigen Seite, wenn ein Item auf Seite 2 aktiv ist. * Initialisiert die Suchfunktion (Overlay + API-Calls).
*/ */
function scrollNavToActive() { function initSearch(container) {
const scroll = document.querySelector('.nav-bottom__scroll'); const searchBtn = container.querySelector('#search-btn');
if (!scroll) return; const searchClose = container.querySelector('#search-close');
const secondPage = navItems().slice(5).map(n => n.path); const overlay = container.querySelector('#search-overlay');
if (secondPage.includes(currentPath)) { const input = container.querySelector('#search-input');
scroll.scrollTo({ left: scroll.offsetWidth, behavior: 'smooth' }); const results = container.querySelector('#search-results');
if (!searchBtn || !overlay || !input || !results) return;
function openSearch() {
if (window._closeMoreSheet) window._closeMoreSheet();
overlay.setAttribute('aria-hidden', 'false');
overlay.classList.add('search-overlay--visible');
setTimeout(() => input.focus(), 50);
if (window.lucide) window.lucide.createIcons();
} }
function closeSearch() {
overlay.setAttribute('aria-hidden', 'true');
overlay.classList.remove('search-overlay--visible');
input.value = '';
results.replaceChildren();
}
searchBtn.addEventListener('click', openSearch);
searchClose.addEventListener('click', closeSearch);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('search-overlay--visible')) {
closeSearch();
}
});
let searchTimer = null;
input.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = input.value.trim();
if (q.length < 2) {
results.replaceChildren();
return;
}
searchTimer = setTimeout(async () => {
try {
const data = await api.get(`/search?q=${encodeURIComponent(q)}`);
renderSearchResults(results, data, closeSearch);
} catch {
// Fehler still ignorieren - kein Overlay-Crash
}
}, 300);
});
}
/**
* Rendert Suchergebnisse in den Ergebnis-Container.
*/
function renderSearchResults(container, data, onClose) {
container.replaceChildren();
const { tasks = [], events = [], notes = [] } = data;
const total = tasks.length + events.length + notes.length;
if (total === 0) {
const empty = document.createElement('p');
empty.className = 'search-overlay__empty';
empty.textContent = t('search.noResults');
container.appendChild(empty);
return;
}
function makeSection(labelKey, items, routeFn) {
if (!items.length) return;
const section = document.createElement('div');
section.className = 'search-section';
const heading = document.createElement('h3');
heading.className = 'search-section__heading';
heading.textContent = t(labelKey);
section.appendChild(heading);
items.forEach((item) => {
const btn = document.createElement('button');
btn.className = 'search-result';
const title = document.createElement('span');
title.className = 'search-result__title';
title.textContent = item.title;
btn.appendChild(title);
btn.addEventListener('click', () => {
onClose();
navigate(routeFn(item));
});
section.appendChild(btn);
});
container.appendChild(section);
}
makeSection('nav.tasks', tasks, (i) => `/tasks?open=${i.id}`);
makeSection('nav.calendar', events, () => '/calendar');
makeSection('nav.notes', notes, (i) => `/notes?open=${i.id}`);
} }
function navItems() { function navItems() {
@@ -397,17 +585,45 @@ function navItems() {
]; ];
} }
function navItemHtml({ path, label, icon }) { function navItemEl({ path, label, icon }) {
return ` const a = document.createElement('a');
<a href="${path}" data-route="${path}" class="nav-item" role="listitem" aria-label="${label}"> a.href = path;
<i data-lucide="${icon}" class="nav-item__icon" aria-hidden="true"></i> a.dataset.route = path;
<span class="nav-item__label">${label}</span> a.className = 'nav-item';
</a> a.setAttribute('role', 'listitem');
`; a.setAttribute('aria-label', label);
const i = document.createElement('i');
i.dataset.lucide = icon;
i.className = 'nav-item__icon';
i.setAttribute('aria-hidden', 'true');
const span = document.createElement('span');
span.className = 'nav-item__label';
span.textContent = label;
a.appendChild(i);
a.appendChild(span);
return a;
}
function moreItemEl({ path, label, icon }) {
const a = document.createElement('a');
a.href = path;
a.dataset.route = path;
a.className = 'more-item';
const i = document.createElement('i');
i.dataset.lucide = icon;
i.className = 'more-item__icon';
i.setAttribute('aria-hidden', 'true');
const span = document.createElement('span');
span.className = 'more-item__label';
span.textContent = label;
a.appendChild(i);
a.appendChild(span);
return a;
} }
/** /**
* Aktiven Nav-Link hervorheben. * Aktiven Nav-Link hervorheben und More-Button als aktiv markieren
* wenn die aktive Route im More-Sheet liegt.
*/ */
function updateNav(path) { function updateNav(path) {
document.querySelectorAll('[data-route]').forEach((el) => { document.querySelectorAll('[data-route]').forEach((el) => {
@@ -417,15 +633,16 @@ function updateNav(path) {
} }
}); });
// Lucide Icons neu rendern (nach DOM-Update) const moreBtn = document.querySelector('#more-btn');
if (moreBtn) {
const inMoreSheet = navItems().slice(PRIMARY_NAV).some((n) => n.path === path);
moreBtn.classList.toggle('nav-item--active', inMoreSheet);
moreBtn.toggleAttribute('aria-current', inMoreSheet);
}
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons(); window.lucide.createIcons();
} }
// Bottom-Nav zur aktiven Seite scrollen
scrollNavToActive();
// Modul-Akzentfarbe wird in navigate() gesetzt, wo route bereits aufgelöst ist.
} }
function renderError(container, err) { function renderError(container, err) {
@@ -548,25 +765,35 @@ window.addEventListener('auth:expired', () => {
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden // Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
window.addEventListener('locale-changed', () => { window.addEventListener('locale-changed', () => {
const skipLink = document.querySelector('.sr-only[href="#main-content"]');
const navSidebar = document.querySelector('.nav-sidebar');
const navSidebarItems = document.querySelector('.nav-sidebar__items'); const navSidebarItems = document.querySelector('.nav-sidebar__items');
const navBottomPages = document.querySelectorAll('.nav-bottom__page'); const navBottom = document.querySelector('.nav-bottom');
const skipLink = document.querySelector('.sr-only[href="#main-content"]'); const bottomItems = document.querySelector('.nav-bottom__items');
const navSidebar = document.querySelector('.nav-sidebar'); const moreSheet = document.querySelector('#more-sheet');
const navBottom = document.querySelector('.nav-bottom'); const moreBtnLabel = document.querySelector('#more-btn .nav-item__label');
if (skipLink) skipLink.textContent = t('common.skipToContent'); if (skipLink) skipLink.textContent = t('common.skipToContent');
if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation'));
if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more');
if (navSidebarItems) { if (navSidebarItems) {
navSidebarItems.innerHTML = navItems().map(navItemHtml).join(''); navSidebarItems.replaceChildren(...navItems().map(navItemEl));
} }
if (navBottomPages.length >= 2) { if (bottomItems) {
navBottomPages[0].innerHTML = navItems().slice(0, 5).map(navItemHtml).join(''); const moreBtn = bottomItems.querySelector('#more-btn');
navBottomPages[1].innerHTML = navItems().slice(5).map(navItemHtml).join(''); const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
bottomItems.replaceChildren(...newItems, moreBtn);
}
if (moreSheet) {
const searchBtn = moreSheet.querySelector('#search-btn');
const searchLbl = searchBtn?.querySelector('.more-item__label');
if (searchLbl) searchLbl.textContent = t('search.title');
const newMoreItems = navItems().slice(PRIMARY_NAV).map(moreItemEl);
moreSheet.replaceChildren(searchBtn, ...newMoreItems);
} }
// Klick-Handler für neu gerenderte Nav-Links
document.querySelectorAll('[data-route]').forEach((el) => { document.querySelectorAll('[data-route]').forEach((el) => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
@@ -574,7 +801,6 @@ window.addEventListener('locale-changed', () => {
}); });
}); });
// Aktiven Zustand und Icons wiederherstellen
updateNav(currentPath); updateNav(currentPath);
}); });
+198 -34
View File
@@ -134,48 +134,212 @@
padding-bottom: var(--safe-area-inset-bottom); padding-bottom: var(--safe-area-inset-bottom);
} }
/* ── Dot-Indikator ── */ /* ── Items-Reihe ── */
.nav-bottom__dots { .nav-bottom__items {
display: flex; display: flex;
justify-content: center;
gap: var(--space-2);
padding: var(--space-1) 0 var(--space-0h);
}
.nav-bottom__dot {
width: var(--space-1);
height: var(--space-1);
border-radius: var(--radius-full);
background-color: var(--color-text-tertiary);
opacity: 0.25;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav-bottom__dot--active {
opacity: 0.7;
transform: scale(1.2);
}
/* ── Scroll-Container ── */
.nav-bottom__scroll {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
height: var(--nav-height-mobile); height: var(--nav-height-mobile);
} }
.nav-bottom__scroll::-webkit-scrollbar { /* ── More-Button (Button-Basis, ansonsten wie nav-item) ── */
display: none; /* Chrome/Safari */ .nav-item--more {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
} }
/* ── Einzelne Seiten ── */ .nav-item--active {
.nav-bottom__page { color: var(--active-module-accent, var(--color-accent));
}
/* ── More-Backdrop ── */
.more-backdrop {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: calc(var(--z-nav) + 1);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.more-backdrop--visible {
display: block;
animation: fade-in 0.15s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── More-Sheet ── */
.more-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--color-surface);
border-top: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: var(--space-4) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
z-index: calc(var(--z-nav) + 2);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
}
.more-sheet[aria-hidden="false"] {
transform: translateY(0);
}
/* ── More-Item ── */
.more-item {
display: flex; display: flex;
min-width: 100%; flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
border-radius: var(--radius-lg);
background-color: var(--color-surface-elevated);
color: var(--color-text-secondary);
text-decoration: none;
border: none;
cursor: pointer;
font-family: inherit;
transition: background-color var(--transition-fast), color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
min-height: var(--target-lg);
}
.more-item:active {
background-color: var(--color-surface-hover);
transform: scale(0.96);
}
.more-item[aria-current="page"] {
color: var(--active-module-accent, var(--color-accent));
background-color: color-mix(in srgb, var(--active-module-accent, var(--color-accent)) 12%, transparent);
}
.more-item__icon {
width: var(--space-6);
height: var(--space-6);
flex-shrink: 0; flex-shrink: 0;
scroll-snap-align: start; }
.more-item__label {
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
text-align: center;
line-height: 1.2;
}
/* ── Such-Overlay ── */
.search-overlay {
position: fixed;
inset: 0;
background-color: var(--color-surface);
z-index: calc(var(--z-nav) + 3);
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
}
.search-overlay--visible {
transform: translateY(0);
}
.search-overlay__header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: calc(var(--space-4) + var(--safe-area-inset-top)) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.search-overlay__input {
flex: 1;
height: var(--target-lg);
padding: 0 var(--space-3);
border-radius: var(--radius-lg);
border: 1.5px solid var(--color-border);
background-color: var(--color-surface-elevated);
color: var(--color-text-primary);
font-size: var(--text-base);
}
.search-overlay__input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-overlay__close {
display: flex;
align-items: center;
justify-content: center;
width: var(--target-base);
height: var(--target-base);
border-radius: var(--radius-full);
border: none;
background: var(--color-surface-elevated);
color: var(--color-text-secondary);
cursor: pointer;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.search-overlay__close-icon {
width: var(--space-5);
height: var(--space-5);
}
.search-overlay__results {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.search-overlay__empty {
text-align: center;
color: var(--color-text-tertiary);
margin-top: var(--space-8);
}
.search-section {
margin-bottom: var(--space-5);
}
.search-section__heading {
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-2);
}
.search-result {
display: block;
width: 100%;
text-align: left;
padding: var(--space-3);
border-radius: var(--radius-md);
border: none;
background: var(--color-surface-elevated);
color: var(--color-text-primary);
font-size: var(--text-sm);
cursor: pointer;
margin-bottom: var(--space-2);
transition: background-color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.search-result:active {
background-color: var(--color-surface-hover);
} }
/* ── Nav-Item (Bottom-Bar): Basis-State ── */ /* ── Nav-Item (Bottom-Bar): Basis-State ── */
+20
View File
@@ -302,6 +302,26 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
/* #11 Swipe-Affordanz: subtiler grüner Streifen am rechten Rand (nur Touch-Geräte) */
@media (pointer: coarse) {
.swipe-row::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
background: linear-gradient(to left, color-mix(in srgb, var(--color-success) 60%, transparent), transparent);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}
.swipe-row--swiping::after {
opacity: 0;
}
}
/* -------------------------------------------------------- /* --------------------------------------------------------
* Task-Card * Task-Card
* -------------------------------------------------------- */ * -------------------------------------------------------- */
+2
View File
@@ -25,6 +25,7 @@ import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js'; import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js'; import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.js'; import remindersRouter from './routes/reminders.js';
import searchRouter from './routes/search.js';
const log = createLogger('Server'); const log = createLogger('Server');
const logSync = createLogger('Sync'); const logSync = createLogger('Sync');
@@ -167,6 +168,7 @@ app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter); app.use('/api/v1/reminders', remindersRouter);
app.use('/api/v1/search', searchRouter);
// -------------------------------------------------------- // --------------------------------------------------------
// Health-Check (für Docker) // Health-Check (für Docker)
+62
View File
@@ -0,0 +1,62 @@
/**
* Modul: Globale Suche (Search)
* Zweck: Volltext-Suche über Aufgaben, Kalender-Events und Notizen
* Abhängigkeiten: express, server/db.js
*/
import express from 'express';
import * as db from '../db.js';
const router = express.Router();
const LIMIT = 5;
/**
* GET /api/v1/search?q=<query>
* Durchsucht Aufgaben, Kalender-Events und Notizen des Nutzers.
* Response: { tasks: Task[], events: Event[], notes: Note[] }
*/
router.get('/', (req, res) => {
try {
const q = String(req.query.q ?? '').trim();
if (q.length < 2) return res.json({ tasks: [], events: [], notes: [] });
const like = `%${q}%`;
const userId = req.session.userId;
const tasks = db.get().prepare(`
SELECT id, title, status, priority, due_date
FROM tasks
WHERE parent_task_id IS NULL
AND (created_by = ? OR assigned_to = ?)
AND (title LIKE ? OR description LIKE ?)
ORDER BY CASE status WHEN 'done' THEN 1 ELSE 0 END,
due_date ASC NULLS LAST
LIMIT ?
`).all(userId, userId, like, like, LIMIT);
const events = db.get().prepare(`
SELECT id, title, start_datetime, all_day
FROM calendar_events
WHERE created_by = ?
AND (title LIKE ? OR description LIKE ?)
ORDER BY start_datetime ASC
LIMIT ?
`).all(userId, like, like, LIMIT);
const notes = db.get().prepare(`
SELECT id, title, content
FROM notes
WHERE created_by = ?
AND (title LIKE ? OR content LIKE ?)
ORDER BY pinned DESC, updated_at DESC
LIMIT ?
`).all(userId, like, like, LIMIT);
res.json({ tasks, events, notes });
} catch (err) {
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
export default router;
+2 -2
View File
@@ -20,8 +20,8 @@ const router = express.Router();
const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent']; const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'];
const VALID_STATUSES = ['open', 'in_progress', 'done']; const VALID_STATUSES = ['open', 'in_progress', 'done'];
const VALID_CATEGORIES = ['Haushalt', 'Schule', 'Einkauf', 'Reparatur', const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges']; 'health', 'finance', 'leisure', 'misc'];
// -------------------------------------------------------- // --------------------------------------------------------
// Hilfsfunktionen // Hilfsfunktionen