Merge branch 'ulsklyc:main' into main

This commit is contained in:
Rafael Foster
2026-04-29 15:30:32 -03:00
committed by GitHub
28 changed files with 460 additions and 85 deletions
+27
View File
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.34.1] - 2026-04-29
### Fixed
- Kitchen tabs bar disappeared after navigating to Shopping, because the page overwrote the container a second time after loading data
## [0.34.0] - 2026-04-29
### Added
- Navigation: new "Küche" (Kitchen) button in the bottom bar groups Meals, Recipes and Shopping behind a single entry point with a persistent tab bar inside each sub-module
- Navigation: new "Suche" (Search) button added to the bottom bar for one-tap access to the search overlay
- Kitchen tabs bar: sticky segment-control (Meals / Recipes / Shopping) injected at the top of each sub-module page; remembers the last active tab via sessionStorage
- Keyboard shortcuts: `g k` navigates to Kitchen (last tab), `g k m` → Meals, `g k r` → Recipes, `g k s` → Shopping
- i18n: `nav.kitchen`, `nav.search` and `shortcuts.goKitchen` keys added to all 15 locale files
### Changed
- Navigation: bottom bar reorganised — Dashboard, Calendar, Küche, Suche, Mehr (5 items)
- Navigation: Meals, Recipes and Shopping removed from the More sheet; they are accessible via the Kitchen tab bar and the sidebar on desktop
- More sheet: reduced from 3-column to 2-column grid for larger touch targets; search trigger removed
- More sheet: drag-handle added at the top; swipe-down gesture closes the sheet
## [0.33.1] - 2026-04-29
### Changed
- Navigation: removed the dedicated Search button from the bottom bar; the bottom bar now shows three primary module links plus the More button
- Navigation: the More sheet now opens with a full-width pill-shaped search trigger at the top, replacing the grid-cell search item
- Search: the search overlay input field is now positioned at the bottom of the screen (thumb zone) instead of the top
## [0.33.0] - 2026-04-29
### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oikos",
"version": "0.33.0",
"version": "0.34.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oikos",
"version": "0.33.0",
"version": "0.34.1",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "oikos",
"version": "0.33.0",
"version": "0.34.1",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
@@ -20,12 +20,13 @@
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
"test:ux-utils": "node test-ux-utils.js",
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
"test:kitchen-tabs": "node --loader ./test-browser-loader.mjs test-kitchen-tabs.js",
"test:reminders": "node --experimental-sqlite test-reminders.js",
"test:api": "node test-api.js",
"test:setup": "node test-setup.js",
"test:ics-parser": "node test-ics-parser.js",
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup"
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs"
},
"dependencies": {
"bcrypt": "^6.0.0",
+1
View File
@@ -37,6 +37,7 @@
<link rel="stylesheet" href="/styles/pwa.css" />
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/glass.css" />
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
<link rel="stylesheet" href="/styles/login.css" />
<!-- Theme: explizite Nutzer-Overrides vor CSS-Rendering anwenden (Flash-Prevention).
System-Präferenz wird durch @media (prefers-color-scheme: dark) in tokens.css
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "الإجراءات السريعة",
"recipes": "الوصفات",
"more": "المزيد",
"documents": "المستندات"
"documents": "المستندات",
"kitchen": "المطبخ",
"search": "بحث"
},
"dashboard": {
"title": "لوحة التحكم",
@@ -985,5 +987,8 @@
"dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار",
"dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.",
"selectedFileLabel": "المحدد: {{name}}"
},
"shortcuts": {
"goKitchen": "المطبخ"
}
}
+5 -2
View File
@@ -47,7 +47,9 @@
"quickActions": "Schnellaktionen",
"more": "Mehr",
"recipes": "Rezepte",
"documents": "Dokumente"
"documents": "Dokumente",
"kitchen": "Küche",
"search": "Suche"
},
"search": {
"title": "Suche",
@@ -961,7 +963,8 @@
"goTasks": "Aufgaben",
"goCal": "Kalender",
"goShop": "Einkaufsliste",
"goNotes": "Notizen"
"goNotes": "Notizen",
"goKitchen": "Küche"
},
"documents": {
"title": "Dokumente",
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Γρήγορες ενέργειες",
"recipes": "Συνταγές",
"more": "Περισσότερα",
"documents": "Έγγραφα"
"documents": "Έγγραφα",
"kitchen": "Κουζίνα",
"search": "Αναζήτηση"
},
"dashboard": {
"title": "Επισκόπηση",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή",
"dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.",
"selectedFileLabel": "Επιλέχθηκε: {{name}}"
},
"shortcuts": {
"goKitchen": "Κουζίνα"
}
}
+5 -2
View File
@@ -47,7 +47,9 @@
"quickActions": "Quick actions",
"recipes": "Recipes",
"more": "More",
"documents": "Documents"
"documents": "Documents",
"kitchen": "Kitchen",
"search": "Search"
},
"dashboard": {
"title": "Overview",
@@ -931,7 +933,8 @@
"goTasks": "Tasks",
"goCal": "Calendar",
"goShop": "Shopping list",
"goNotes": "Notes"
"goNotes": "Notes",
"goKitchen": "Kitchen"
},
"emptyHint": {
"tasks": "Tap + to create your first task. Swipe a card left to delete.",
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Acciones rápidas",
"recipes": "Recetas",
"more": "Más",
"documents": "Documentos"
"documents": "Documentos",
"kitchen": "Cocina",
"search": "Buscar"
},
"dashboard": {
"title": "Inicio",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Suelta el archivo aquí o haz clic para elegir",
"dropzoneHint": "Arrastra un archivo a esta área o usa el selector de archivos.",
"selectedFileLabel": "Seleccionado: {{name}}"
},
"shortcuts": {
"goKitchen": "Cocina"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Actions rapides",
"recipes": "Recettes",
"more": "Plus",
"documents": "Documents"
"documents": "Documents",
"kitchen": "Cuisine",
"search": "Recherche"
},
"dashboard": {
"title": "Accueil",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Déposez le fichier ici ou cliquez pour choisir",
"dropzoneHint": "Glissez un fichier dans cette zone ou utilisez le sélecteur.",
"selectedFileLabel": "Sélectionné : {{name}}"
},
"shortcuts": {
"goKitchen": "Cuisine"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "त्वरित क्रियाएं",
"recipes": "रेसिपी",
"more": "और",
"documents": "दस्तावेज़"
"documents": "दस्तावेज़",
"kitchen": "रसोई",
"search": "खोज"
},
"dashboard": {
"title": "डैशबोर्ड",
@@ -985,5 +987,8 @@
"dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें",
"dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।",
"selectedFileLabel": "चयनित: {{name}}"
},
"shortcuts": {
"goKitchen": "रसोई"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Azioni rapide",
"recipes": "Ricette",
"more": "Altro",
"documents": "Documenti"
"documents": "Documenti",
"kitchen": "Cucina",
"search": "Cerca"
},
"dashboard": {
"title": "Panoramica",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Rilascia il file qui o fai clic per scegliere",
"dropzoneHint": "Trascina un file in questarea oppure usa il selettore.",
"selectedFileLabel": "Selezionato: {{name}}"
},
"shortcuts": {
"goKitchen": "Cucina"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "クイックアクション",
"recipes": "レシピ",
"more": "もっと見る",
"documents": "書類"
"documents": "書類",
"kitchen": "キッチン",
"search": "検索"
},
"dashboard": {
"title": "ダッシュボード",
@@ -985,5 +987,8 @@
"dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択",
"dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。",
"selectedFileLabel": "選択済み: {{name}}"
},
"shortcuts": {
"goKitchen": "キッチン"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Ações rápidas",
"recipes": "Receitas",
"more": "Mais",
"documents": "Documentos"
"documents": "Documentos",
"kitchen": "Cozinha",
"search": "Pesquisar"
},
"dashboard": {
"title": "Painel",
@@ -986,5 +988,8 @@
"dropzoneTitle": "Solte o arquivo aqui ou clique para escolher",
"dropzoneHint": "Arraste um arquivo para esta area, ou use o seletor de arquivos.",
"selectedFileLabel": "Selecionado: {{name}}"
},
"shortcuts": {
"goKitchen": "Cozinha"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Быстрые действия",
"recipes": "Рецепты",
"more": "Ещё",
"documents": "Документы"
"documents": "Документы",
"kitchen": "Кухня",
"search": "Поиск"
},
"dashboard": {
"title": "Обзор",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Перетащите файл сюда или нажмите для выбора",
"dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.",
"selectedFileLabel": "Выбрано: {{name}}"
},
"shortcuts": {
"goKitchen": "Кухня"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Snabba åtgärder",
"recipes": "Recept",
"more": "Mer",
"documents": "Dokument"
"documents": "Dokument",
"kitchen": "Kök",
"search": "Sök"
},
"dashboard": {
"title": "Översikt",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Släpp filen här eller klicka för att välja",
"dropzoneHint": "Dra en fil till området eller använd filväljaren.",
"selectedFileLabel": "Vald: {{name}}"
},
"shortcuts": {
"goKitchen": "Kök"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Hızlı işlemler",
"recipes": "Tarifler",
"more": "Daha Fazla",
"documents": "Belgeler"
"documents": "Belgeler",
"kitchen": "Mutfak",
"search": "Ara"
},
"dashboard": {
"title": "Genel Bakış",
@@ -985,5 +987,8 @@
"dropzoneTitle": "Dosyayı buraya bırakın veya seçmek için tıklayın",
"dropzoneHint": "Bir dosyayı bu alana sürükleyin veya dosya seçiciyi kullanın.",
"selectedFileLabel": "Seçildi: {{name}}"
},
"shortcuts": {
"goKitchen": "Mutfak"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "Швидкі дії",
"recipes": "Рецепти",
"more": "Більше",
"documents": "Документи"
"documents": "Документи",
"kitchen": "Кухня",
"search": "Пошук"
},
"dashboard": {
"title": "Огляд",
@@ -993,5 +995,8 @@
"dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору",
"dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.",
"selectedFileLabel": "Вибрано: {{name}}"
},
"shortcuts": {
"goKitchen": "Кухня"
}
}
+6 -1
View File
@@ -47,7 +47,9 @@
"quickActions": "快捷操作",
"recipes": "食谱",
"more": "更多",
"documents": "文档"
"documents": "文档",
"kitchen": "厨房",
"search": "搜索"
},
"dashboard": {
"title": "概览",
@@ -985,5 +987,8 @@
"dropzoneTitle": "将文件拖到此处或点击选择",
"dropzoneHint": "将文件拖入此区域,或使用文件选择器。",
"selectedFileLabel": "已选择:{{name}}"
},
"shortcuts": {
"goKitchen": "厨房"
}
}
+2
View File
@@ -10,6 +10,7 @@ import { stagger } from '/utils/ux.js';
import { t, formatDate, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
import { esc } from '/utils/html.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
// --------------------------------------------------------
// Konstanten
@@ -163,6 +164,7 @@ export async function render(container, { user }) {
`;
if (window.lucide) lucide.createIcons();
renderKitchenTabsBar(container, '/meals');
const today = new Date().toISOString().slice(0, 10);
const monday = getMondayOf(today);
+2
View File
@@ -7,6 +7,7 @@ import { api } from '/api.js';
import { t } from '/i18n.js';
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
let _container = null;
@@ -70,6 +71,7 @@ export async function render(container) {
page.append(header, list, fab);
container.replaceChildren(page);
renderKitchenTabsBar(container, '/recipes');
if (window.lucide) window.lucide.createIcons();
+2 -1
View File
@@ -10,6 +10,7 @@ import { t } from '/i18n.js';
import { esc } from '/utils/html.js';
import { promptModal } from '/components/modal.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
// --------------------------------------------------------
// Konstanten
@@ -839,7 +840,6 @@ export async function render(container, { user }) {
</div>
</div>
`;
try {
await Promise.all([loadCategories(), loadLists()]);
if (state.lists.length) {
@@ -862,6 +862,7 @@ export async function render(container, { user }) {
</div>
`;
renderKitchenTabsBar(container, '/shopping');
renderTabs(container);
wireTabBar(container);
renderListContent(container);
+96 -57
View File
@@ -8,6 +8,7 @@ import { api, auth } from '/api.js';
import { initI18n, getLocale, t } from '/i18n.js';
import { esc } from '/utils/html.js';
import { init as initReminders, stop as stopReminders } from '/reminders.js';
import { isKitchenRoute, getLastKitchenRoute } from '/utils/kitchen-tabs.js';
// --------------------------------------------------------
// Routen-Definitionen
@@ -128,10 +129,10 @@ let _pendingLoginRedirect = false;
// Router
// --------------------------------------------------------
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
'/notes', '/contacts', '/budget', '/documents', '/settings'];
const ROUTE_ORDER = ['/', '/calendar', '/tasks', '/meals', '/recipes', '/shopping',
'/birthdays', '/notes', '/contacts', '/budget', '/documents', '/settings'];
const PRIMARY_NAV = 3;
const PRIMARY_NAV = 2;
const DEFAULT_APP_NAME = 'Oikos';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
@@ -478,20 +479,42 @@ function renderAppShell(container) {
const bottomItems = document.createElement('div');
bottomItems.className = 'nav-bottom__items';
navItems().slice(0, PRIMARY_NAV).forEach((item) => bottomItems.appendChild(navItemEl(item)));
const kitchenBtn = document.createElement('button');
kitchenBtn.className = 'nav-item nav-item--kitchen';
kitchenBtn.id = 'kitchen-btn';
kitchenBtn.type = 'button';
kitchenBtn.setAttribute('aria-label', t('nav.kitchen'));
kitchenBtn.setAttribute('title', t('nav.kitchen'));
const kitchenBtnIcon = document.createElement('i');
kitchenBtnIcon.dataset.lucide = 'utensils';
kitchenBtnIcon.className = 'nav-item__icon';
kitchenBtnIcon.setAttribute('aria-hidden', 'true');
const kitchenBtnLabel = document.createElement('span');
kitchenBtnLabel.className = 'nav-item__label';
kitchenBtnLabel.textContent = t('nav.kitchen');
kitchenBtn.appendChild(kitchenBtnIcon);
kitchenBtn.appendChild(kitchenBtnLabel);
kitchenBtn.addEventListener('click', () => navigate(getLastKitchenRoute()));
bottomItems.appendChild(kitchenBtn);
const searchNavBtn = document.createElement('button');
searchNavBtn.className = 'nav-item nav-item--search';
searchNavBtn.id = 'search-nav-btn';
searchNavBtn.setAttribute('aria-label', t('search.title'));
searchNavBtn.id = 'search-btn';
searchNavBtn.type = 'button';
searchNavBtn.setAttribute('aria-label', t('nav.search'));
searchNavBtn.setAttribute('title', t('nav.search'));
const searchNavIcon = document.createElement('i');
searchNavIcon.dataset.lucide = 'search';
searchNavIcon.className = 'nav-item__icon';
searchNavIcon.setAttribute('aria-hidden', 'true');
const searchNavLabel = document.createElement('span');
searchNavLabel.className = 'nav-item__label';
searchNavLabel.textContent = t('search.title');
searchNavLabel.textContent = t('nav.search');
searchNavBtn.appendChild(searchNavIcon);
searchNavBtn.appendChild(searchNavLabel);
bottomItems.appendChild(searchNavBtn);
const moreBtn = document.createElement('button');
moreBtn.className = 'nav-item nav-item--more';
moreBtn.id = 'more-btn';
@@ -520,20 +543,11 @@ function renderAppShell(container) {
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 dragHandle = document.createElement('div');
dragHandle.className = 'more-sheet__handle';
dragHandle.setAttribute('aria-hidden', 'true');
moreSheet.insertAdjacentElement('afterbegin', dragHandle);
navItems().filter((i) => !i.sidebarOnly).slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item)));
const searchOverlay = document.createElement('div');
searchOverlay.className = 'search-overlay';
@@ -601,7 +615,11 @@ const SHORTCUTS = [
{ key: 'g t', description: () => t('shortcuts.goTasks'), action: () => navigate('/tasks') },
{ key: 'g c', description: () => t('shortcuts.goCal'), action: () => navigate('/calendar') },
{ key: 'g s', description: () => t('shortcuts.goShop'), action: () => navigate('/shopping') },
{ key: 'g n', description: () => t('shortcuts.goNotes'), action: () => navigate('/notes') },
{ key: 'g n', description: () => t('shortcuts.goNotes'), action: () => navigate('/notes') },
{ key: 'g k', description: () => t('shortcuts.goKitchen'), action: () => navigate(getLastKitchenRoute()) },
{ key: 'g k m', description: () => t('shortcuts.goKitchen'), action: () => navigate('/meals') },
{ key: 'g k r', description: () => t('shortcuts.goKitchen'), action: () => navigate('/recipes') },
{ key: 'g k s', description: () => t('shortcuts.goKitchen'), action: () => navigate('/shopping') },
];
let _pendingKey = null;
@@ -616,8 +634,32 @@ function initKeyboardShortcuts() {
const key = e.key.toLowerCase();
// 3-Tasten-Chord: g k {m|r|s}
if (_pendingKey === 'g k') {
clearTimeout(_pendingTimer);
_pendingKey = null;
const chord3 = `g k ${key}`;
const s3 = SHORTCUTS.find((s) => s.key === chord3);
if (s3) { e.preventDefault(); s3.action(); return; }
// Kein 3-Chord-Match → g k selbst ausführen
const gk = SHORTCUTS.find((s) => s.key === 'g k');
if (gk) { e.preventDefault(); gk.action(); }
return;
}
// 2-Tasten-Chord: g {d|t|c|s|n|k}
if (_pendingKey === 'g' && key !== 'g') {
clearTimeout(_pendingTimer);
if (key === 'k') {
// k ist Präfix für 3-Chord — auf dritten Tastendruck warten
_pendingKey = 'g k';
_pendingTimer = setTimeout(() => {
_pendingKey = null;
const gk = SHORTCUTS.find((s) => s.key === 'g k');
if (gk) gk.action();
}, 1000);
return;
}
_pendingKey = null;
const combo = `g ${key}`;
const shortcut = SHORTCUTS.find((s) => s.key === combo);
@@ -752,6 +794,14 @@ function initMoreSheet(container) {
backdrop.addEventListener('click', closeSheet);
let _touchStartY = 0;
sheet.addEventListener('touchstart', (e) => {
_touchStartY = e.touches[0].clientY;
}, { passive: true });
sheet.addEventListener('touchend', (e) => {
if (e.changedTouches[0].clientY - _touchStartY > 60) closeSheet();
}, { passive: true });
sheet.querySelectorAll('[data-route]').forEach((el) => {
el.addEventListener('click', () => closeSheet());
});
@@ -763,9 +813,8 @@ function initMoreSheet(container) {
* Initialisiert die Suchfunktion (Overlay + API-Calls).
*/
function initSearch(container) {
const searchBtn = container.querySelector('#search-btn');
const searchNavBtn = container.querySelector('#search-nav-btn');
const searchClose = container.querySelector('#search-close');
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');
@@ -812,7 +861,6 @@ function initSearch(container) {
}
searchBtn.addEventListener('click', openSearch);
if (searchNavBtn) searchNavBtn.addEventListener('click', openSearch);
searchClose.addEventListener('click', closeSearch);
document.addEventListener('keydown', (e) => {
@@ -888,17 +936,19 @@ function renderSearchResults(container, data, onClose) {
function navItems() {
return [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
// More-Sheet Items:
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock' },
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
// Sidebar only (Küche-Gruppe):
{ path: '/meals', label: t('nav.meals'), icon: 'utensils', sidebarOnly: true },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text', sidebarOnly: true },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', sidebarOnly: true },
];
}
@@ -951,9 +1001,16 @@ function updateNav(path) {
}
});
const kitchenNavBtn = document.querySelector('#kitchen-btn');
if (kitchenNavBtn) {
const isKitchen = isKitchenRoute(path);
kitchenNavBtn.classList.toggle('nav-item--active', isKitchen);
kitchenNavBtn.toggleAttribute('aria-current', isKitchen);
}
const moreBtn = document.querySelector('#more-btn');
if (moreBtn) {
const secondaryItems = navItems().slice(PRIMARY_NAV);
const secondaryItems = navItems().filter((i) => !i.sidebarOnly).slice(PRIMARY_NAV);
const activeSecondary = secondaryItems.find((n) => n.path === path);
const inMoreSheet = !!activeSecondary;
@@ -1135,36 +1192,18 @@ window.addEventListener('locale-changed', () => {
navSidebarItems.replaceChildren(...navItems().map(navItemEl));
}
if (bottomItems) {
const moreBtn = bottomItems.querySelector('#more-btn');
const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn');
const searchBtnEl = bottomItems.querySelector('#search-btn');
const moreBtn = bottomItems.querySelector('#more-btn');
if (kitchenBtnEl) kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen');
if (searchBtnEl) searchBtnEl.querySelector('.nav-item__label').textContent = t('nav.search');
const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
// Such-Button neu erstellen (wird durch replaceChildren entfernt)
const newSearchBtn = document.createElement('button');
newSearchBtn.className = 'nav-item nav-item--search';
newSearchBtn.id = 'search-nav-btn';
newSearchBtn.setAttribute('aria-label', t('search.title'));
const newSearchIcon = document.createElement('i');
newSearchIcon.dataset.lucide = 'search';
newSearchIcon.className = 'nav-item__icon';
newSearchIcon.setAttribute('aria-hidden', 'true');
const newSearchLbl = document.createElement('span');
newSearchLbl.className = 'nav-item__label';
newSearchLbl.textContent = t('search.title');
newSearchBtn.appendChild(newSearchIcon);
newSearchBtn.appendChild(newSearchLbl);
bottomItems.replaceChildren(...newItems, newSearchBtn, moreBtn);
// Event-Listener auf neuen Such-Button
if (newSearchBtn) {
newSearchBtn.addEventListener('click', () => {
if (window._openSearch) window._openSearch();
});
}
bottomItems.replaceChildren(...newItems, kitchenBtnEl, searchBtnEl, 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);
const handle = moreSheet.querySelector('.more-sheet__handle');
const newMoreItems = navItems().filter((i) => !i.sidebarOnly).slice(PRIMARY_NAV).map(moreItemEl);
moreSheet.replaceChildren(handle, ...newMoreItems);
}
document.querySelectorAll('[data-route]').forEach((el) => {
+86
View File
@@ -0,0 +1,86 @@
/* Modul: Kitchen Tabs Bar
* Sticky Segment-Control für Mahlzeiten/Rezepte/Einkauf + Sub-Modul Layout-Fixes
*/
.kitchen-tabs-bar {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
height: var(--kitchen-tabs-height);
box-sizing: border-box;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border-subtle);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
flex-shrink: 0;
}
.kitchen-tabs-bar::-webkit-scrollbar {
display: none;
}
.kitchen-tab {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-3);
height: 36px;
border-radius: var(--radius-full);
border: none;
background: transparent;
color: var(--color-text-secondary);
font-family: inherit;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background-color var(--transition-fast), color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.kitchen-tab:active {
transform: scale(0.96);
transition-duration: 0.06s;
}
.kitchen-tab--active {
background-color: color-mix(in srgb, var(--active-module-accent, var(--color-accent)) 14%, transparent);
color: var(--active-module-accent, var(--color-accent));
}
.kitchen-tab__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.kitchen-tab__label {
line-height: 1;
}
/* Mahlzeiten: sticky day-header unterhalb der Tab-Leiste */
.has-kitchen-tabs .day-header {
top: var(--kitchen-tabs-height);
}
/* Einkauf: Viewport-Höhe um Tab-Leiste reduzieren (Mobile) */
.has-kitchen-tabs .shopping-page {
height: calc(
100dvh
- var(--nav-bottom-height)
- var(--safe-area-inset-bottom)
- var(--kitchen-tabs-height)
);
}
/* Einkauf: Viewport-Höhe (Desktop) */
@media (min-width: 1024px) {
.has-kitchen-tabs .shopping-page {
height: calc(100dvh - var(--kitchen-tabs-height));
}
}
+19 -6
View File
@@ -172,7 +172,9 @@
}
/* ── More-Button (Button-Basis, ansonsten wie nav-item) ── */
.nav-item--more {
.nav-item--more,
.nav-item--kitchen,
.nav-item--search {
background: none;
border: none;
cursor: pointer;
@@ -216,7 +218,7 @@
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(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
@@ -226,6 +228,16 @@
transform: translateY(0);
}
/* ── More-Sheet Drag-Handle ── */
.more-sheet__handle {
grid-column: 1 / -1;
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background-color: var(--color-border);
margin: 0 auto var(--space-2);
}
/* ── More-Item ── */
.more-item {
display: flex;
@@ -275,7 +287,7 @@
background-color: var(--color-surface);
z-index: calc(var(--z-nav) + 3);
display: flex;
flex-direction: column;
flex-direction: column-reverse;
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
}
@@ -288,8 +300,8 @@
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);
padding: var(--space-3) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
border-top: 1px solid var(--color-border-subtle);
}
.search-overlay__input {
@@ -331,7 +343,8 @@
.search-overlay__results {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
padding: var(--space-4) var(--space-4) var(--space-2);
padding-top: calc(var(--space-4) + var(--safe-area-inset-top));
}
.search-overlay__empty {
+1
View File
@@ -351,6 +351,7 @@
--content-max-width: 1280px;
--content-max-width-narrow: 720px;
--cal-hour-height: 56px;
--kitchen-tabs-height: 56px;
/* --------------------------------------------------------
* 13. Sidebar
+68
View File
@@ -0,0 +1,68 @@
import { t } from '/i18n.js';
export const KITCHEN_ROUTES = ['/meals', '/recipes', '/shopping'];
export const KITCHEN_STORAGE_KEY = 'oikos-kitchen-tab';
const TABS = () => [
{ route: '/meals', labelKey: 'nav.meals', icon: 'utensils' },
{ route: '/recipes', labelKey: 'nav.recipes', icon: 'book-text' },
{ route: '/shopping', labelKey: 'nav.shopping', icon: 'shopping-cart' },
];
export function getLastKitchenRoute() {
try {
const stored = sessionStorage.getItem(KITCHEN_STORAGE_KEY);
return KITCHEN_ROUTES.includes(stored) ? stored : '/meals';
} catch {
return '/meals';
}
}
export function isKitchenRoute(path) {
return KITCHEN_ROUTES.includes(path);
}
export function renderKitchenTabsBar(container, activeRoute) {
try {
sessionStorage.setItem(KITCHEN_STORAGE_KEY, activeRoute);
} catch { /* ignore */ }
container.classList.add('has-kitchen-tabs');
const bar = document.createElement('div');
bar.className = 'kitchen-tabs-bar';
bar.setAttribute('role', 'tablist');
bar.setAttribute('aria-label', t('nav.kitchen'));
TABS().forEach(({ route, labelKey, icon }) => {
const btn = document.createElement('button');
btn.className = 'kitchen-tab' + (route === activeRoute ? ' kitchen-tab--active' : '');
btn.dataset.route = route;
btn.type = 'button';
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', route === activeRoute ? 'true' : 'false');
const i = document.createElement('i');
i.dataset.lucide = icon;
i.className = 'kitchen-tab__icon';
i.setAttribute('aria-hidden', 'true');
const span = document.createElement('span');
span.className = 'kitchen-tab__label';
span.textContent = t(labelKey);
btn.appendChild(i);
btn.appendChild(span);
bar.appendChild(btn);
});
bar.addEventListener('click', (e) => {
const btn = e.target.closest('[data-route]');
if (!btn || btn.dataset.route === activeRoute) return;
window.oikos?.navigate(btn.dataset.route);
});
container.insertAdjacentElement('afterbegin', bar);
if (window.lucide) window.lucide.createIcons({ el: bar });
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Tests: Kitchen-Tabs Utility (pure functions)
* Läuft mit: node --loader ./test-browser-loader.mjs test-kitchen-tabs.js
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { KITCHEN_ROUTES, KITCHEN_STORAGE_KEY, getLastKitchenRoute, isKitchenRoute } = await (async () => {
global.window = { oikos: null };
global.document = {
createElement: () => ({
className: '', dataset: {}, style: {},
setAttribute() {}, appendChild() {},
classList: { add() {}, toggle() {} },
insertAdjacentElement() {},
addEventListener() {},
}),
};
const storage = {
_d: {},
getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = v; },
};
global.sessionStorage = storage;
global.t = (k) => k;
return import('./public/utils/kitchen-tabs.js');
})();
test('KITCHEN_ROUTES enthält alle drei Sub-Routen', () => {
assert.deepEqual(KITCHEN_ROUTES, ['/meals', '/recipes', '/shopping']);
});
test('KITCHEN_STORAGE_KEY ist korrekt', () => {
assert.equal(KITCHEN_STORAGE_KEY, 'oikos-kitchen-tab');
});
test('getLastKitchenRoute: Standardwert /meals wenn kein Storage-Eintrag', () => {
global.sessionStorage._d = {};
assert.equal(getLastKitchenRoute(), '/meals');
});
test('getLastKitchenRoute: gibt gespeicherte Route zurück', () => {
global.sessionStorage._d = { 'oikos-kitchen-tab': '/recipes' };
assert.equal(getLastKitchenRoute(), '/recipes');
});
test('getLastKitchenRoute: ignoriert ungültige gespeicherte Route', () => {
global.sessionStorage._d = { 'oikos-kitchen-tab': '/admin' };
assert.equal(getLastKitchenRoute(), '/meals');
});
test('isKitchenRoute: erkennt Kitchen-Routen', () => {
assert.equal(isKitchenRoute('/meals'), true);
assert.equal(isKitchenRoute('/recipes'), true);
assert.equal(isKitchenRoute('/shopping'), true);
});
test('isKitchenRoute: lehnt Nicht-Kitchen-Routen ab', () => {
assert.equal(isKitchenRoute('/tasks'), false);
assert.equal(isKitchenRoute('/'), false);
assert.equal(isKitchenRoute('/calendar'), false);
assert.equal(isKitchenRoute(''), false);
});