diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cb80a..52b998a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added diff --git a/package-lock.json b/package-lock.json index c0f2058..fab6e4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.20.23", + "version": "0.20.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.20.23", + "version": "0.20.24", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index ecbd6f9..460ca00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index 21c42c8..ae35bfe 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -42,7 +42,14 @@ "settings": "Einstellungen", "main": "Hauptnavigation", "navigation": "Navigation", - "quickActions": "Schnellaktionen" + "quickActions": "Schnellaktionen", + "more": "Mehr" + }, + "search": { + "title": "Suche", + "placeholder": "Suchen…", + "noResults": "Keine Ergebnisse gefunden.", + "open": "Suche öffnen" }, "dashboard": { "title": "Übersicht", diff --git a/public/router.js b/public/router.js index d65c797..c1a578f 100644 --- a/public/router.js +++ b/public/router.js @@ -4,7 +4,7 @@ * Abhängigkeiten: api.js */ -import { auth } from '/api.js'; +import { api, auth } from '/api.js'; import { initI18n, getLocale, t } from '/i18n.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', '/notes', '/contacts', '/budget', '/settings']; +const PRIMARY_NAV = 4; + function getDirection(fromPath, toPath) { const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/'); 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). */ function renderAppShell(container) { - container.innerHTML = ` - ${t('common.skipToContent')} - + const skipLink = document.createElement('a'); + skipLink.href = '#main-content'; + skipLink.className = 'sr-only'; + skipLink.textContent = t('common.skipToContent'); -
-
+ const sidebar = document.createElement('nav'); + 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); - + const main = document.createElement('main'); + main.className = 'app-content'; + main.id = 'main-content'; + main.setAttribute('aria-live', 'polite'); -
- `; + 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 container.querySelectorAll('[data-route]').forEach((el) => { @@ -323,11 +405,9 @@ function renderAppShell(container) { }); }); - // Bottom-Nav: Scroll-Snap + Dot-Indikator - initBottomNavSwipe(container); - - // Bottom-Nav: Auto-Hide beim Runterscrollen (Mobile) + initMoreSheet(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) { - const scroll = container.querySelector('.nav-bottom__scroll'); - const dots = container.querySelectorAll('.nav-bottom__dot'); - if (!scroll || !dots.length) return; +function initMoreSheet(container) { + const moreBtn = container.querySelector('#more-btn'); + const backdrop = container.querySelector('#more-backdrop'); + const sheet = container.querySelector('#more-sheet'); + if (!moreBtn || !backdrop || !sheet) return; - // Scroll-Event: Dot-Indikator aktualisieren - scroll.addEventListener('scroll', () => { - const page = Math.round(scroll.scrollLeft / scroll.offsetWidth); - dots.forEach((d, i) => d.classList.toggle('nav-bottom__dot--active', i === page)); - }, { passive: true }); + function openSheet() { + sheet.setAttribute('aria-hidden', 'false'); + backdrop.classList.add('more-backdrop--visible'); + moreBtn.setAttribute('aria-expanded', '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() { - const scroll = document.querySelector('.nav-bottom__scroll'); - if (!scroll) return; - const secondPage = navItems().slice(5).map(n => n.path); - if (secondPage.includes(currentPath)) { - scroll.scrollTo({ left: scroll.offsetWidth, behavior: 'smooth' }); +function initSearch(container) { + const searchBtn = container.querySelector('#search-btn'); + const searchClose = container.querySelector('#search-close'); + const overlay = container.querySelector('#search-overlay'); + const input = container.querySelector('#search-input'); + 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() { @@ -397,17 +585,45 @@ function navItems() { ]; } -function navItemHtml({ path, label, icon }) { - return ` - - - ${label} - - `; +function navItemEl({ path, label, icon }) { + const a = document.createElement('a'); + a.href = path; + a.dataset.route = path; + a.className = 'nav-item'; + 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) { 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) { 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) { @@ -548,25 +765,35 @@ window.addEventListener('auth:expired', () => { // Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden 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 navBottomPages = document.querySelectorAll('.nav-bottom__page'); - const skipLink = document.querySelector('.sr-only[href="#main-content"]'); - const navSidebar = document.querySelector('.nav-sidebar'); - const navBottom = document.querySelector('.nav-bottom'); + const navBottom = document.querySelector('.nav-bottom'); + const bottomItems = document.querySelector('.nav-bottom__items'); + const moreSheet = document.querySelector('#more-sheet'); + const moreBtnLabel = document.querySelector('#more-btn .nav-item__label'); - if (skipLink) skipLink.textContent = t('common.skipToContent'); - if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); - if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); + if (skipLink) skipLink.textContent = t('common.skipToContent'); + if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); + if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); + if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more'); if (navSidebarItems) { - navSidebarItems.innerHTML = navItems().map(navItemHtml).join(''); + navSidebarItems.replaceChildren(...navItems().map(navItemEl)); } - if (navBottomPages.length >= 2) { - navBottomPages[0].innerHTML = navItems().slice(0, 5).map(navItemHtml).join(''); - navBottomPages[1].innerHTML = navItems().slice(5).map(navItemHtml).join(''); + if (bottomItems) { + const moreBtn = bottomItems.querySelector('#more-btn'); + 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) => { el.addEventListener('click', (e) => { e.preventDefault(); @@ -574,7 +801,6 @@ window.addEventListener('locale-changed', () => { }); }); - // Aktiven Zustand und Icons wiederherstellen updateNav(currentPath); }); diff --git a/public/styles/layout.css b/public/styles/layout.css index ad95331..a04a0d1 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -134,48 +134,212 @@ padding-bottom: var(--safe-area-inset-bottom); } -/* ── Dot-Indikator ── */ -.nav-bottom__dots { +/* ── Items-Reihe ── */ +.nav-bottom__items { 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); } -.nav-bottom__scroll::-webkit-scrollbar { - display: none; /* Chrome/Safari */ +/* ── More-Button (Button-Basis, ansonsten wie nav-item) ── */ +.nav-item--more { + background: none; + border: none; + cursor: pointer; + font-family: inherit; } -/* ── Einzelne Seiten ── */ -.nav-bottom__page { +.nav-item--active { + 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; - 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; - 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 ── */ diff --git a/public/styles/tasks.css b/public/styles/tasks.css index 88d01ca..bf9a830 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -302,6 +302,26 @@ 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 * -------------------------------------------------------- */ diff --git a/server/index.js b/server/index.js index 4cce3df..d463224 100644 --- a/server/index.js +++ b/server/index.js @@ -25,6 +25,7 @@ import budgetRouter from './routes/budget.js'; import weatherRouter from './routes/weather.js'; import preferencesRouter from './routes/preferences.js'; import remindersRouter from './routes/reminders.js'; +import searchRouter from './routes/search.js'; const log = createLogger('Server'); const logSync = createLogger('Sync'); @@ -167,6 +168,7 @@ app.use('/api/v1/budget', budgetRouter); app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/reminders', remindersRouter); +app.use('/api/v1/search', searchRouter); // -------------------------------------------------------- // Health-Check (für Docker) diff --git a/server/routes/search.js b/server/routes/search.js new file mode 100644 index 0000000..6121d35 --- /dev/null +++ b/server/routes/search.js @@ -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= + * 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; diff --git a/server/routes/tasks.js b/server/routes/tasks.js index bbd156e..2950e1e 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -20,8 +20,8 @@ const router = express.Router(); const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent']; const VALID_STATUSES = ['open', 'in_progress', 'done']; -const VALID_CATEGORIES = ['Haushalt', 'Schule', 'Einkauf', 'Reparatur', - 'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges']; +const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair', + 'health', 'finance', 'leisure', 'misc']; // -------------------------------------------------------- // Hilfsfunktionen