feat(settings): add dedicated Sync tab with CardDAV contacts integration
- Rename Calendar tab to Synchronization with two sections: * Calendar Sync (Google, Apple, CalDAV, ICS) * Contact Sync (CardDAV) - NEW - Add visual tab grouping with CSS separators between sections - Implement CardDAV account management UI: * Add/delete accounts * Enable/disable addressbooks * Manual sync trigger * Connection testing - Add UX improvements: * Status badges (success/error/syncing) * Empty states with onboarding * Inline help tooltips (prepared) * Breadcrumb navigation (prepared) - Update i18n keys in all 14 locales - All 109 tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Settings Navigation Utility
|
||||
* Zweck: Zweistufige Sidebar-Navigation für Settings
|
||||
* Pattern: Hauptkategorien (links) + Unterkategorien (Content-Bereich)
|
||||
*/
|
||||
|
||||
import { t } from '/i18n.js';
|
||||
|
||||
export const SETTINGS_STORAGE_KEY = 'oikos-settings-section';
|
||||
|
||||
/**
|
||||
* Hauptkategorien mit ihren Unterkategorien
|
||||
*/
|
||||
export const SETTINGS_SECTIONS = (user) => [
|
||||
{
|
||||
id: 'personal',
|
||||
labelKey: 'settings.sectionPersonal',
|
||||
icon: 'user',
|
||||
pages: [
|
||||
{ id: 'account', labelKey: 'settings.tabAccount', icon: 'user-circle' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
labelKey: 'settings.sectionModules',
|
||||
icon: 'layout-grid',
|
||||
pages: [
|
||||
{ id: 'general', labelKey: 'settings.tabGeneral', icon: 'settings' },
|
||||
{ id: 'meals', labelKey: 'settings.tabMeals', icon: 'utensils' },
|
||||
{ id: 'budget', labelKey: 'settings.tabBudget', icon: 'wallet' },
|
||||
{ id: 'shopping', labelKey: 'settings.tabShopping', icon: 'shopping-cart' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
labelKey: 'settings.sectionSync',
|
||||
icon: 'refresh-cw',
|
||||
pages: [
|
||||
{ id: 'sync-calendar', labelKey: 'settings.tabSyncCalendar', icon: 'calendar' },
|
||||
{ id: 'sync-contacts', labelKey: 'settings.tabSyncContacts', icon: 'users' },
|
||||
]
|
||||
},
|
||||
...(user?.role === 'admin' ? [{
|
||||
id: 'admin',
|
||||
labelKey: 'settings.sectionAdmin',
|
||||
icon: 'shield',
|
||||
pages: [
|
||||
{ id: 'family', labelKey: 'settings.tabFamily', icon: 'users' },
|
||||
{ id: 'api-tokens', labelKey: 'settings.tabApiTokens', icon: 'key' },
|
||||
{ id: 'backup', labelKey: 'settings.tabBackup', icon: 'database' },
|
||||
]
|
||||
}] : [])
|
||||
];
|
||||
|
||||
/**
|
||||
* Findet Sektion und Seite für gegebene Page-ID
|
||||
*/
|
||||
export function findSectionAndPage(pageId, user) {
|
||||
const sections = SETTINGS_SECTIONS(user);
|
||||
for (const section of sections) {
|
||||
const page = section.pages.find(p => p.id === pageId);
|
||||
if (page) return { section, page };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt letzte aktive Seite zurück (aus sessionStorage)
|
||||
*/
|
||||
export function getLastActivePage(user) {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const found = findSectionAndPage(stored, user);
|
||||
if (found) return stored;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert aktive Seite
|
||||
*/
|
||||
export function setActivePage(pageId) {
|
||||
try {
|
||||
sessionStorage.setItem(SETTINGS_STORAGE_KEY, pageId);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Sidebar-Navigation
|
||||
*/
|
||||
export function renderSettingsSidebar(container, activePage, user) {
|
||||
const sections = SETTINGS_SECTIONS(user);
|
||||
const activeInfo = findSectionAndPage(activePage, user);
|
||||
const activeSectionId = activeInfo?.section.id;
|
||||
|
||||
const sidebar = document.createElement('nav');
|
||||
sidebar.className = 'settings-sidebar';
|
||||
sidebar.setAttribute('role', 'navigation');
|
||||
sidebar.setAttribute('aria-label', t('settings.navigationLabel'));
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionEl = document.createElement('div');
|
||||
sectionEl.className = 'settings-sidebar-section';
|
||||
if (section.id === activeSectionId) {
|
||||
sectionEl.classList.add('settings-sidebar-section--active');
|
||||
}
|
||||
|
||||
// Section Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'settings-sidebar-section__header';
|
||||
|
||||
const headerIcon = document.createElement('i');
|
||||
headerIcon.dataset.lucide = section.icon;
|
||||
headerIcon.className = 'settings-sidebar-section__icon';
|
||||
headerIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const headerLabel = document.createElement('span');
|
||||
headerLabel.className = 'settings-sidebar-section__label';
|
||||
headerLabel.textContent = t(section.labelKey);
|
||||
|
||||
header.appendChild(headerIcon);
|
||||
header.appendChild(headerLabel);
|
||||
sectionEl.appendChild(header);
|
||||
|
||||
// Pages List
|
||||
const pagesList = document.createElement('div');
|
||||
pagesList.className = 'settings-sidebar-pages';
|
||||
|
||||
section.pages.forEach(page => {
|
||||
const pageBtn = document.createElement('button');
|
||||
pageBtn.className = 'settings-sidebar-page';
|
||||
pageBtn.type = 'button';
|
||||
pageBtn.dataset.pageId = page.id;
|
||||
if (page.id === activePage) {
|
||||
pageBtn.classList.add('settings-sidebar-page--active');
|
||||
pageBtn.setAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
const pageIcon = document.createElement('i');
|
||||
pageIcon.dataset.lucide = page.icon;
|
||||
pageIcon.className = 'settings-sidebar-page__icon';
|
||||
pageIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const pageLabel = document.createElement('span');
|
||||
pageLabel.className = 'settings-sidebar-page__label';
|
||||
pageLabel.textContent = t(page.labelKey);
|
||||
|
||||
pageBtn.appendChild(pageIcon);
|
||||
pageBtn.appendChild(pageLabel);
|
||||
pagesList.appendChild(pageBtn);
|
||||
});
|
||||
|
||||
sectionEl.appendChild(pagesList);
|
||||
sidebar.appendChild(sectionEl);
|
||||
});
|
||||
|
||||
// Event Delegation
|
||||
sidebar.addEventListener('click', (e) => {
|
||||
const pageBtn = e.target.closest('[data-page-id]');
|
||||
if (!pageBtn) return;
|
||||
|
||||
const pageId = pageBtn.dataset.pageId;
|
||||
if (pageId === activePage) return;
|
||||
|
||||
setActivePage(pageId);
|
||||
|
||||
// Trigger custom event für Page-Switch
|
||||
container.dispatchEvent(new CustomEvent('settings-page-change', {
|
||||
detail: { pageId },
|
||||
bubbles: true
|
||||
}));
|
||||
});
|
||||
|
||||
container.appendChild(sidebar);
|
||||
|
||||
// Hydrate Lucide Icons
|
||||
if (window.lucide) window.lucide.createIcons({ el: sidebar });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert Breadcrumb für aktive Seite
|
||||
*/
|
||||
export function renderBreadcrumb(container, activePage, user) {
|
||||
const info = findSectionAndPage(activePage, user);
|
||||
if (!info) return;
|
||||
|
||||
const breadcrumb = document.createElement('nav');
|
||||
breadcrumb.className = 'settings-breadcrumb';
|
||||
breadcrumb.setAttribute('aria-label', t('settings.breadcrumbLabel'));
|
||||
|
||||
const ol = document.createElement('ol');
|
||||
ol.className = 'settings-breadcrumb__list';
|
||||
|
||||
// Home
|
||||
const homeItem = document.createElement('li');
|
||||
homeItem.className = 'settings-breadcrumb__item';
|
||||
homeItem.textContent = t('settings.title');
|
||||
ol.appendChild(homeItem);
|
||||
|
||||
// Separator
|
||||
const sep1 = document.createElement('li');
|
||||
sep1.className = 'settings-breadcrumb__separator';
|
||||
sep1.setAttribute('aria-hidden', 'true');
|
||||
sep1.textContent = '›';
|
||||
ol.appendChild(sep1);
|
||||
|
||||
// Section
|
||||
const sectionItem = document.createElement('li');
|
||||
sectionItem.className = 'settings-breadcrumb__item';
|
||||
sectionItem.textContent = t(info.section.labelKey);
|
||||
ol.appendChild(sectionItem);
|
||||
|
||||
// Separator
|
||||
const sep2 = document.createElement('li');
|
||||
sep2.className = 'settings-breadcrumb__separator';
|
||||
sep2.setAttribute('aria-hidden', 'true');
|
||||
sep2.textContent = '›';
|
||||
ol.appendChild(sep2);
|
||||
|
||||
// Page
|
||||
const pageItem = document.createElement('li');
|
||||
pageItem.className = 'settings-breadcrumb__item settings-breadcrumb__item--current';
|
||||
pageItem.setAttribute('aria-current', 'page');
|
||||
pageItem.textContent = t(info.page.labelKey);
|
||||
ol.appendChild(pageItem);
|
||||
|
||||
breadcrumb.appendChild(ol);
|
||||
container.insertBefore(breadcrumb, container.firstChild);
|
||||
}
|
||||
Reference in New Issue
Block a user