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:
Ulas Kalayci
2026-05-04 21:50:59 +02:00
parent 43225ee20c
commit 6cdef0102c
21 changed files with 4267 additions and 43 deletions
+231
View File
@@ -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);
}