Merge pull request #127 from ulsklyc/refactor/settings-sync-restructure
refactor(settings): Sync-Tab — CalDAV/CardDAV priorisiert, Heading-Bug gefixt
This commit is contained in:
@@ -42,6 +42,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/pwa.css" />
|
<link rel="stylesheet" href="/styles/pwa.css" />
|
||||||
<link rel="stylesheet" href="/styles/layout.css" />
|
<link rel="stylesheet" href="/styles/layout.css" />
|
||||||
<link rel="stylesheet" href="/styles/glass.css" />
|
<link rel="stylesheet" href="/styles/glass.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/sub-tabs.css" />
|
||||||
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
|
|
||||||
|
|||||||
@@ -773,6 +773,8 @@
|
|||||||
"tabSyncCalendar": "Kalender",
|
"tabSyncCalendar": "Kalender",
|
||||||
"tabSyncContacts": "Kontakte",
|
"tabSyncContacts": "Kontakte",
|
||||||
"sectionContactSync": "Kontakt-Synchronisation",
|
"sectionContactSync": "Kontakt-Synchronisation",
|
||||||
|
"sectionOpenStandards": "CalDAV & CardDAV",
|
||||||
|
"sectionCloudServices": "Cloud-Dienste",
|
||||||
"cardavTitle": "CardDAV Kontakte",
|
"cardavTitle": "CardDAV Kontakte",
|
||||||
"tabFamily": "Familie",
|
"tabFamily": "Familie",
|
||||||
"tabApiTokens": "API-Tokens",
|
"tabApiTokens": "API-Tokens",
|
||||||
|
|||||||
+125
-122
@@ -9,6 +9,7 @@ import { openModal, closeModal, confirmModal } from '/components/modal.js';
|
|||||||
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js';
|
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
import { renderSettingsSidebar, renderBreadcrumb, getLastActivePage, setActivePage, findSectionAndPage } from '/utils/settings-nav.js';
|
import { renderSettingsSidebar, renderBreadcrumb, getLastActivePage, setActivePage, findSectionAndPage } from '/utils/settings-nav.js';
|
||||||
|
import { renderSubTabs } from '/utils/sub-tabs.js';
|
||||||
import '/components/oikos-locale-picker.js';
|
import '/components/oikos-locale-picker.js';
|
||||||
|
|
||||||
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
||||||
@@ -256,8 +257,6 @@ export async function render(container, { user }) {
|
|||||||
: (allowedTabs.includes(storedTab) ? storedTab : 'general');
|
: (allowedTabs.includes(storedTab) ? storedTab : 'general');
|
||||||
|
|
||||||
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
|
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
|
||||||
const btnClass = (id) => `settings-tab-btn${id === activeTab ? ' settings-tab-btn--active' : ''}`;
|
|
||||||
const btnAria = (id) => id === activeTab ? 'true' : 'false';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page settings-page">
|
<div class="page settings-page">
|
||||||
@@ -268,18 +267,6 @@ export async function render(container, { user }) {
|
|||||||
${syncOk ? `<div class="settings-banner settings-banner--success">${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}</div>` : ''}
|
${syncOk ? `<div class="settings-banner settings-banner--success">${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}</div>` : ''}
|
||||||
${syncErr ? `<div class="settings-banner settings-banner--error">${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}</div>` : ''}
|
${syncErr ? `<div class="settings-banner settings-banner--error">${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}</div>` : ''}
|
||||||
|
|
||||||
<nav class="settings-tabs" role="tablist" aria-label="${t('settings.tabsAriaLabel')}">
|
|
||||||
<button class="${btnClass('general')}" role="tab" data-tab="general" aria-selected="${btnAria('general')}">${t('settings.tabGeneral')}</button>
|
|
||||||
<button class="${btnClass('meals')}" role="tab" data-tab="meals" aria-selected="${btnAria('meals')}">${t('settings.tabMeals')}</button>
|
|
||||||
<button class="${btnClass('budget')}" role="tab" data-tab="budget" aria-selected="${btnAria('budget')}">${t('settings.tabBudget')}</button>
|
|
||||||
<button class="${btnClass('shopping')}" role="tab" data-tab="shopping" aria-selected="${btnAria('shopping')}">${t('settings.tabShopping')}</button>
|
|
||||||
<button class="${btnClass('sync')}" role="tab" data-tab="sync" aria-selected="${btnAria('sync')}" data-group="sync">${t('settings.tabSync')}</button>
|
|
||||||
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}" data-group="personal">${t('settings.tabAccount')}</button>
|
|
||||||
${user?.role === 'admin' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}" data-group="admin">${t('settings.tabFamily')}</button>` : ''}
|
|
||||||
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}" data-group="admin">${t('settings.tabApiTokens')}</button>` : ''}
|
|
||||||
${user?.role === 'admin' ? `<button class="${btnClass('backup')}" role="tab" data-tab="backup" aria-selected="${btnAria('backup')}" data-group="admin">${t('settings.tabBackup')}</button>` : ''}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Panel: Allgemein (Design + Sprache) -->
|
<!-- Panel: Allgemein (Design + Sprache) -->
|
||||||
<div class="settings-tab-panel" data-panel="general" role="tabpanel"${panelHidden('general')}>
|
<div class="settings-tab-panel" data-panel="general" role="tabpanel"${panelHidden('general')}>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
@@ -456,9 +443,85 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
<!-- Panel: Synchronisation -->
|
<!-- Panel: Synchronisation -->
|
||||||
<div class="settings-tab-panel" data-panel="sync" role="tabpanel"${panelHidden('sync')}>
|
<div class="settings-tab-panel" data-panel="sync" role="tabpanel"${panelHidden('sync')}>
|
||||||
<!-- Sektion: Kalender-Synchronisation -->
|
|
||||||
|
<!-- Sektion: Offene Standards (CalDAV · CardDAV · ICS) -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">${t('settings.sectionCalendarSync')}</h2>
|
<h2 class="settings-section__title">${t('settings.sectionOpenStandards')}</h2>
|
||||||
|
|
||||||
|
<!-- CalDAV Kalender -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.caldavTitle')}</h3>
|
||||||
|
<p class="settings-card-description">${t('settings.caldavDescription')}</p>
|
||||||
|
|
||||||
|
<div id="caldav-accounts-list"></div>
|
||||||
|
<div id="caldav-empty-state" class="caldav-empty-state" style="display: none;">
|
||||||
|
<p>${t('settings.caldavEmptyState')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${user?.role === 'admin' ? `
|
||||||
|
<button class="btn btn--primary" id="caldav-add-account-btn">
|
||||||
|
${t('settings.caldavAddAccount')}
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CardDAV Kontakte -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.cardavTitle')}</h3>
|
||||||
|
<p class="settings-card-description">${t('settings.cardavDescription')}</p>
|
||||||
|
|
||||||
|
<div id="cardav-accounts-list"></div>
|
||||||
|
<div id="cardav-empty-state" class="caldav-empty-state" style="display: none;">
|
||||||
|
<p>${t('settings.cardavEmptyState')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${user?.role === 'admin' ? `
|
||||||
|
<button class="btn btn--primary" id="cardav-add-account-btn">
|
||||||
|
${t('settings.cardavAddAccount')}
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ICS-Abonnements -->
|
||||||
|
<div class="settings-card" id="ics-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.ics.title')}</h3>
|
||||||
|
<div id="ics-list-container"></div>
|
||||||
|
<div id="ics-add-form-wrapper" hidden>
|
||||||
|
<form id="ics-add-form" class="settings-form settings-form--compact" novalidate autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="ics-url">${t('settings.ics.form.url')}</label>
|
||||||
|
<input class="form-input" type="url" id="ics-url" required placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="ics-name">${t('settings.ics.form.name')}</label>
|
||||||
|
<input class="form-input" type="text" id="ics-name" required maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="ics-color">${t('settings.ics.form.color')}</label>
|
||||||
|
<input class="form-input form-input--color" type="color" id="ics-color" value="#6366f1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" id="ics-shared" />
|
||||||
|
<span>${t('settings.ics.form.shared')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="ics-add-error" class="form-error" hidden></div>
|
||||||
|
<div class="settings-form-actions">
|
||||||
|
<button type="submit" class="btn btn--primary" id="ics-submit-btn">${t('settings.ics.actions.submit')}</button>
|
||||||
|
<button type="button" class="btn btn--secondary" id="ics-cancel-btn">${t('settings.ics.actions.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="settings-sync-actions">
|
||||||
|
<button class="btn btn--secondary" id="ics-add-btn">${t('settings.ics.add')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sektion: Cloud-Dienste (Google · Apple) -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">${t('settings.sectionCloudServices')}</h2>
|
||||||
|
|
||||||
<!-- Google Calendar -->
|
<!-- Google Calendar -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@@ -530,84 +593,6 @@ export async function render(container, { user }) {
|
|||||||
</form>
|
</form>
|
||||||
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
|
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CalDAV Kalender -->
|
|
||||||
<div class="settings-card">
|
|
||||||
<h2>${t('settings.caldavTitle')}</h2>
|
|
||||||
<p class="settings-card-description">${t('settings.caldavDescription')}</p>
|
|
||||||
|
|
||||||
<div id="caldav-accounts-list"></div>
|
|
||||||
<div id="caldav-empty-state" class="caldav-empty-state" style="display: none;">
|
|
||||||
<p>${t('settings.caldavEmptyState')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${user?.role === 'admin' ? `
|
|
||||||
<button class="btn btn--primary" id="caldav-add-account-btn">
|
|
||||||
${t('settings.caldavAddAccount')}
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ICS-Abonnements -->
|
|
||||||
<div class="settings-card" id="ics-card">
|
|
||||||
<div class="settings-sync-header">
|
|
||||||
<div class="settings-sync-info">
|
|
||||||
<div class="settings-sync-info__name">${t('settings.ics.title')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="ics-list-container"></div>
|
|
||||||
<div id="ics-add-form-wrapper" hidden>
|
|
||||||
<form id="ics-add-form" class="settings-form settings-form--compact" novalidate autocomplete="off">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="ics-url">${t('settings.ics.form.url')}</label>
|
|
||||||
<input class="form-input" type="url" id="ics-url" required placeholder="https://..." />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="ics-name">${t('settings.ics.form.name')}</label>
|
|
||||||
<input class="form-input" type="text" id="ics-name" required maxlength="100" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="ics-color">${t('settings.ics.form.color')}</label>
|
|
||||||
<input class="form-input form-input--color" type="color" id="ics-color" value="#6366f1" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="toggle-row">
|
|
||||||
<input type="checkbox" id="ics-shared" />
|
|
||||||
<span>${t('settings.ics.form.shared')}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="ics-add-error" class="form-error" hidden></div>
|
|
||||||
<div class="settings-form-actions">
|
|
||||||
<button type="submit" class="btn btn--primary" id="ics-submit-btn">${t('settings.ics.actions.submit')}</button>
|
|
||||||
<button type="button" class="btn btn--secondary" id="ics-cancel-btn">${t('settings.ics.actions.cancel')}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="settings-sync-actions">
|
|
||||||
<button class="btn btn--secondary" id="ics-add-btn">${t('settings.ics.add')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sektion: Kontakt-Synchronisation (CardDAV) -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<h2 class="settings-section__title">${t('settings.sectionContactSync')}</h2>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<h3 class="settings-card__title">${t('settings.cardavTitle')}</h3>
|
|
||||||
<p class="settings-card-description">${t('settings.cardavDescription')}</p>
|
|
||||||
|
|
||||||
<div id="cardav-accounts-list"></div>
|
|
||||||
<div id="cardav-empty-state" class="caldav-empty-state" style="display: none;">
|
|
||||||
<p>${t('settings.cardavEmptyState')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${user?.role === 'admin' ? `
|
|
||||||
<button class="btn btn--primary" id="cardav-add-account-btn">
|
|
||||||
${t('settings.cardavAddAccount')}
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -880,6 +865,7 @@ docker cp oikos:/data/oikos-backup.db ./oikos-backup.db</code></pre>
|
|||||||
loadCardDAVAccounts(container, user);
|
loadCardDAVAccounts(container, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderSettingsSubTabs(container, user, activeTab);
|
||||||
bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
|
bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1153,12 +1139,56 @@ async function loadCardDAVAccounts(container, user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Sub-Tab-Navigation
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSettingsTabs(user) {
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: t('settings.tabGeneral'), icon: 'settings' },
|
||||||
|
{ id: 'meals', label: t('settings.tabMeals'), icon: 'utensils' },
|
||||||
|
{ id: 'budget', label: t('settings.tabBudget'), icon: 'wallet' },
|
||||||
|
{ id: 'shopping', label: t('settings.tabShopping'), icon: 'shopping-cart' },
|
||||||
|
{ id: 'sync', label: t('settings.tabSync'), icon: 'refresh-cw', separatorBefore: true },
|
||||||
|
{ id: 'account', label: t('settings.tabAccount'), icon: 'user', separatorBefore: true },
|
||||||
|
];
|
||||||
|
if (user?.role === 'admin') {
|
||||||
|
tabs.push(
|
||||||
|
{ id: 'family', label: t('settings.tabFamily'), icon: 'users', separatorBefore: true },
|
||||||
|
{ id: 'api-tokens', label: t('settings.tabApiTokens'), icon: 'key' },
|
||||||
|
{ id: 'backup', label: t('settings.tabBackup'), icon: 'database' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettingsSubTabs(container, user, activeTab) {
|
||||||
|
const settingsPage = container.querySelector('.settings-page');
|
||||||
|
if (!settingsPage) return;
|
||||||
|
|
||||||
|
const lastBanner = [...settingsPage.querySelectorAll('.settings-banner')].at(-1);
|
||||||
|
const anchor = lastBanner ?? settingsPage.querySelector('.page__header');
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
renderSubTabs(anchor, {
|
||||||
|
tabs: buildSettingsTabs(user),
|
||||||
|
activeId: activeTab,
|
||||||
|
storageKey: SETTINGS_TAB_KEY,
|
||||||
|
ariaLabel: t('settings.tabsAriaLabel'),
|
||||||
|
insertPosition: 'afterend',
|
||||||
|
onChange: (tabId) => {
|
||||||
|
container.querySelectorAll('[data-panel]').forEach((panel) => {
|
||||||
|
panel.hidden = panel.dataset.panel !== tabId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Event-Binding
|
// Event-Binding
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
|
function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
|
||||||
bindTabEvents(container);
|
|
||||||
bindSettingsDateInputs(container);
|
bindSettingsDateInputs(container);
|
||||||
bindCategoryEvents(container);
|
bindCategoryEvents(container);
|
||||||
bindIcsEvents(container, user, icsSubscriptions);
|
bindIcsEvents(container, user, icsSubscriptions);
|
||||||
@@ -1695,33 +1725,6 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Tab-Navigation
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
function bindTabEvents(container) {
|
|
||||||
const tabList = container.querySelector('.settings-tabs');
|
|
||||||
if (!tabList) return;
|
|
||||||
|
|
||||||
tabList.addEventListener('click', (e) => {
|
|
||||||
const btn = e.target.closest('[data-tab]');
|
|
||||||
if (!btn) return;
|
|
||||||
const tab = btn.dataset.tab;
|
|
||||||
|
|
||||||
tabList.querySelectorAll('[data-tab]').forEach((b) => {
|
|
||||||
const active = b.dataset.tab === tab;
|
|
||||||
b.classList.toggle('settings-tab-btn--active', active);
|
|
||||||
b.setAttribute('aria-selected', String(active));
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('[data-panel]').forEach((panel) => {
|
|
||||||
panel.hidden = panel.dataset.panel !== tab;
|
|
||||||
});
|
|
||||||
|
|
||||||
try { sessionStorage.setItem(SETTINGS_TAB_KEY, tab); } catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function bindDeleteButtons(container, user) {
|
function bindDeleteButtons(container, user) {
|
||||||
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
|
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
|
||||||
btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
|
btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
|
||||||
|
|||||||
@@ -1,66 +1,11 @@
|
|||||||
/* Modul: Kitchen Tabs Bar
|
/* Modul: Kitchen Tabs Bar
|
||||||
* Sticky Segment-Control für Mahlzeiten/Rezepte/Einkauf + Sub-Modul Layout-Fixes
|
* Layout-Fixes für Mahlzeiten/Rezepte/Einkauf bei aktiver Sub-Tab-Leiste.
|
||||||
|
* Tab-Styles kommen aus sub-tabs.css; hier nur Kitchen-spezifische Overrides.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* Höhe auf --kitchen-tabs-height überschreiben (56px statt Standard 52px) */
|
||||||
.kitchen-tabs-bar {
|
.kitchen-tabs-bar {
|
||||||
display: flex;
|
--sub-tabs-height: var(--kitchen-tabs-height);
|
||||||
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: var(--target-base);
|
|
||||||
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 */
|
/* Mahlzeiten: sticky day-header unterhalb der Tab-Leiste */
|
||||||
|
|||||||
@@ -43,89 +43,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
Tab-Navigation
|
Sub-Tab-Navigation (via shared sub-tabs.css)
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
.settings-tabs {
|
/* Abstand zwischen Tab-Leiste und erstem Panel-Inhalt */
|
||||||
display: flex;
|
.settings-page .sub-tabs-bar {
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: var(--color-bg);
|
|
||||||
z-index: 10;
|
|
||||||
padding-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tabs::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--space-3) clamp(var(--space-2), 1.3vw, var(--space-4));
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
|
||||||
min-height: 44px;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab-btn--active {
|
|
||||||
color: var(--color-accent);
|
|
||||||
border-bottom-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
|
||||||
Visuelle Tab-Gruppierung (Vorschlag B)
|
|
||||||
-------------------------------------------------------- */
|
|
||||||
|
|
||||||
/* Trennlinie vor Sync-Gruppe */
|
|
||||||
.settings-tab-btn[data-group="sync"]::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: 20%;
|
|
||||||
height: 60%;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trennlinie vor Personal-Gruppe */
|
|
||||||
.settings-tab-btn[data-group="personal"]::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: 20%;
|
|
||||||
height: 60%;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trennlinie vor Admin-Gruppe */
|
|
||||||
.settings-tab-btn[data-group="admin"]::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: 20%;
|
|
||||||
height: 60%;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/* Sub-Tabs Bar — shared pill-style segment control
|
||||||
|
* Used by kitchen modules, settings, and any future sub-module navigation.
|
||||||
|
* Height: --sub-tabs-height (default from tokens.css); override per context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sub-tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
height: var(--sub-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
height: var(--target-base);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
transition-duration: 0.06s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-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));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab__label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical divider between tab groups */
|
||||||
|
.sub-tabs-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 var(--space-1);
|
||||||
|
}
|
||||||
@@ -351,6 +351,7 @@
|
|||||||
--content-max-width: 1280px;
|
--content-max-width: 1280px;
|
||||||
--content-max-width-narrow: 720px;
|
--content-max-width-narrow: 720px;
|
||||||
--cal-hour-height: 56px;
|
--cal-hour-height: 56px;
|
||||||
|
--sub-tabs-height: 52px;
|
||||||
--kitchen-tabs-height: 56px;
|
--kitchen-tabs-height: 56px;
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
import { renderSubTabs } from '/utils/sub-tabs.js';
|
||||||
|
|
||||||
export const KITCHEN_ROUTES = ['/meals', '/recipes', '/shopping'];
|
export const KITCHEN_ROUTES = ['/meals', '/recipes', '/shopping'];
|
||||||
export const KITCHEN_STORAGE_KEY = 'oikos-kitchen-tab';
|
export const KITCHEN_STORAGE_KEY = 'oikos-kitchen-tab';
|
||||||
@@ -23,46 +24,15 @@ export function isKitchenRoute(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderKitchenTabsBar(container, activeRoute) {
|
export function renderKitchenTabsBar(container, activeRoute) {
|
||||||
try {
|
|
||||||
sessionStorage.setItem(KITCHEN_STORAGE_KEY, activeRoute);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
container.classList.add('has-kitchen-tabs');
|
container.classList.add('has-kitchen-tabs');
|
||||||
|
|
||||||
const bar = document.createElement('div');
|
renderSubTabs(container, {
|
||||||
bar.className = 'kitchen-tabs-bar';
|
tabs: TABS().map(({ route, labelKey, icon }) => ({ id: route, label: t(labelKey), icon })),
|
||||||
bar.setAttribute('role', 'tablist');
|
activeId: activeRoute,
|
||||||
bar.setAttribute('aria-label', t('nav.kitchen'));
|
storageKey: KITCHEN_STORAGE_KEY,
|
||||||
|
extraClass: 'kitchen-tabs-bar',
|
||||||
TABS().forEach(({ route, labelKey, icon }) => {
|
ariaLabel: t('nav.kitchen'),
|
||||||
const btn = document.createElement('button');
|
insertPosition: 'afterbegin',
|
||||||
btn.className = 'kitchen-tab' + (route === activeRoute ? ' kitchen-tab--active' : '');
|
onChange: (route) => window.oikos?.navigate(route),
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Shared sticky sub-tab bar (pill-style).
|
||||||
|
* Used by kitchen modules and settings; extend to any future sub-module nav.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} anchorEl - element relative to which the bar is inserted
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {Array<{id: string, label: string, icon?: string, separatorBefore?: boolean}>} opts.tabs
|
||||||
|
* @param {string} opts.activeId - initially active tab id
|
||||||
|
* @param {Function} opts.onChange - called with new id on tab switch
|
||||||
|
* @param {string} [opts.storageKey] - sessionStorage key for persistence
|
||||||
|
* @param {string} [opts.extraClass] - additional CSS class on bar element
|
||||||
|
* @param {string} [opts.ariaLabel]
|
||||||
|
* @param {InsertPosition} [opts.insertPosition='afterbegin']
|
||||||
|
* @returns {HTMLElement} the rendered bar element
|
||||||
|
*/
|
||||||
|
export function renderSubTabs(anchorEl, {
|
||||||
|
tabs,
|
||||||
|
activeId,
|
||||||
|
onChange,
|
||||||
|
storageKey,
|
||||||
|
extraClass,
|
||||||
|
ariaLabel,
|
||||||
|
insertPosition = 'afterbegin',
|
||||||
|
}) {
|
||||||
|
let current = activeId;
|
||||||
|
|
||||||
|
if (storageKey) {
|
||||||
|
try { sessionStorage.setItem(storageKey, current); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'sub-tabs-bar' + (extraClass ? ' ' + extraClass : '');
|
||||||
|
bar.setAttribute('role', 'tablist');
|
||||||
|
if (ariaLabel) bar.setAttribute('aria-label', ariaLabel);
|
||||||
|
|
||||||
|
for (const { id, label, icon, separatorBefore } of tabs) {
|
||||||
|
if (separatorBefore) {
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'sub-tabs-separator';
|
||||||
|
sep.setAttribute('aria-hidden', 'true');
|
||||||
|
bar.appendChild(sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'sub-tab' + (id === current ? ' sub-tab--active' : '');
|
||||||
|
btn.dataset.tabId = id;
|
||||||
|
btn.setAttribute('role', 'tab');
|
||||||
|
btn.setAttribute('aria-selected', id === current ? 'true' : 'false');
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const i = document.createElement('i');
|
||||||
|
i.dataset.lucide = icon;
|
||||||
|
i.className = 'sub-tab__icon';
|
||||||
|
i.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.appendChild(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'sub-tab__label';
|
||||||
|
span.textContent = label;
|
||||||
|
btn.appendChild(span);
|
||||||
|
|
||||||
|
bar.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-tab-id]');
|
||||||
|
if (!btn || btn.dataset.tabId === current) return;
|
||||||
|
|
||||||
|
current = btn.dataset.tabId;
|
||||||
|
|
||||||
|
if (storageKey) {
|
||||||
|
try { sessionStorage.setItem(storageKey, current); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.querySelectorAll('[data-tab-id]').forEach((b) => {
|
||||||
|
const active = b.dataset.tabId === current;
|
||||||
|
b.classList.toggle('sub-tab--active', active);
|
||||||
|
b.setAttribute('aria-selected', String(active));
|
||||||
|
});
|
||||||
|
|
||||||
|
onChange(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
anchorEl.insertAdjacentElement(insertPosition, bar);
|
||||||
|
|
||||||
|
if (window.lucide) window.lucide.createIcons({ el: bar });
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
@@ -25,5 +25,10 @@ export async function resolve(specifier, context, nextResolve) {
|
|||||||
url: `data:text/javascript,${encodeURIComponent(STUBS[specifier])}`,
|
url: `data:text/javascript,${encodeURIComponent(STUBS[specifier])}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Browser-absolute paths (/foo.js, /utils/bar.js) → public/foo.js, public/utils/bar.js
|
||||||
|
if (specifier.startsWith('/') && !specifier.startsWith('//')) {
|
||||||
|
const resolved = new URL('public' + specifier, import.meta.url).href;
|
||||||
|
return nextResolve(resolved, context);
|
||||||
|
}
|
||||||
return nextResolve(specifier, context);
|
return nextResolve(specifier, context);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user