chore: release v0.20.24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+296
-70
@@ -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 navSidebarItems = document.querySelector('.nav-sidebar__items');
|
|
||||||
const navBottomPages = document.querySelectorAll('.nav-bottom__page');
|
|
||||||
const skipLink = document.querySelector('.sr-only[href="#main-content"]');
|
const skipLink = document.querySelector('.sr-only[href="#main-content"]');
|
||||||
const navSidebar = document.querySelector('.nav-sidebar');
|
const navSidebar = document.querySelector('.nav-sidebar');
|
||||||
|
const navSidebarItems = document.querySelector('.nav-sidebar__items');
|
||||||
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 (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
@@ -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 ── */
|
||||||
|
|||||||
@@ -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
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user