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
@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings Sidebar Navigation - Demo</title>
<style>
:root {
--color-bg: #ffffff;
--color-surface: #fafafa;
--color-surface-2: #f5f5f5;
--color-border: #e5e5e5;
--color-text-primary: #171717;
--color-text-secondary: #737373;
--color-text-tertiary: #a3a3a3;
--color-accent: #4F46E5;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-lg: 16px;
--text-2xl: 24px;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--target-base: 36px;
--transition-fast: 0.15s ease;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #18181b;
--color-surface: #27272a;
--color-surface-2: #3f3f46;
--color-border: #3f3f46;
--color-text-primary: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text-primary);
}
/* Layout */
.settings-layout {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
/* Sidebar */
.settings-sidebar {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-6) 0;
overflow-y: auto;
}
.settings-sidebar-section {
margin-bottom: var(--space-6);
}
.settings-sidebar-section__header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
margin-bottom: var(--space-2);
color: var(--color-text-tertiary);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-sidebar-section--active .settings-sidebar-section__header {
color: var(--color-accent);
}
.settings-sidebar-pages {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.settings-sidebar-page {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
margin: 0 var(--space-2);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
font-family: inherit;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast), color var(--transition-fast);
min-height: var(--target-base);
}
.settings-sidebar-page:hover {
background: var(--color-surface-2);
color: var(--color-text-primary);
}
.settings-sidebar-page--active {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
font-weight: var(--font-weight-semibold);
}
/* Content */
.settings-content {
padding: var(--space-6);
max-width: 900px;
}
.settings-breadcrumb {
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.settings-breadcrumb__current {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.settings-page-header__title {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-2);
}
.settings-page-header__description {
font-size: var(--text-base);
color: var(--color-text-secondary);
line-height: 1.6;
margin-bottom: var(--space-6);
}
.settings-card {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
border: 1px solid var(--color-border);
}
.settings-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-8);
text-align: center;
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-2);
}
.settings-empty-state__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
margin: var(--space-4) 0 var(--space-2);
}
.settings-empty-state__description {
color: var(--color-text-secondary);
margin-bottom: var(--space-4);
}
.btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: none;
background: var(--color-accent);
color: white;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="settings-layout">
<!-- Sidebar -->
<nav class="settings-sidebar">
<!-- Persönlich -->
<div class="settings-sidebar-section">
<div class="settings-sidebar-section__header">
<span>👤</span>
<span>Persönlich</span>
</div>
<div class="settings-sidebar-pages">
<button class="settings-sidebar-page">
<span>🔐</span>
<span>Konto</span>
</button>
</div>
</div>
<!-- Module -->
<div class="settings-sidebar-section">
<div class="settings-sidebar-section__header">
<span>🧩</span>
<span>Module</span>
</div>
<div class="settings-sidebar-pages">
<button class="settings-sidebar-page settings-sidebar-page--active">
<span>⚙️</span>
<span>Allgemein</span>
</button>
<button class="settings-sidebar-page">
<span>🍽️</span>
<span>Mahlzeiten</span>
</button>
<button class="settings-sidebar-page">
<span>💰</span>
<span>Budget</span>
</button>
<button class="settings-sidebar-page">
<span>🛒</span>
<span>Einkauf</span>
</button>
</div>
</div>
<!-- Synchronisation -->
<div class="settings-sidebar-section settings-sidebar-section--active">
<div class="settings-sidebar-section__header">
<span>🔄</span>
<span>Synchronisation</span>
</div>
<div class="settings-sidebar-pages">
<button class="settings-sidebar-page">
<span>📅</span>
<span>Kalender</span>
</button>
<button class="settings-sidebar-page">
<span>👥</span>
<span>Kontakte</span>
</button>
</div>
</div>
<!-- Administration -->
<div class="settings-sidebar-section">
<div class="settings-sidebar-section__header">
<span>🛡️</span>
<span>Administration</span>
</div>
<div class="settings-sidebar-pages">
<button class="settings-sidebar-page">
<span>👨‍👩‍👧‍👦</span>
<span>Familie</span>
</button>
<button class="settings-sidebar-page">
<span>🔑</span>
<span>API-Tokens</span>
</button>
<button class="settings-sidebar-page">
<span>💾</span>
<span>Backup</span>
</button>
</div>
</div>
</nav>
<!-- Content -->
<main class="settings-content">
<nav class="settings-breadcrumb">
Einstellungen Module <span class="settings-breadcrumb__current">Allgemein</span>
</nav>
<header class="settings-page-header">
<h1 class="settings-page-header__title">Allgemein</h1>
<p class="settings-page-header__description">
Grundlegende Einstellungen für Design, Sprache und Module.
</p>
</header>
<div class="settings-card">
<h3 style="margin-bottom: 12px;">Design</h3>
<p style="color: var(--color-text-secondary); font-size: var(--text-sm);">
Wähle zwischen hellem, dunklem oder System-Modus.
</p>
</div>
<div class="settings-card">
<h3 style="margin-bottom: 12px;">Sprache</h3>
<p style="color: var(--color-text-secondary); font-size: var(--text-sm);">
Ändere die Anzeigesprache der Anwendung.
</p>
</div>
<!-- Demo: Kontakte-Sync Page -->
<div style="display: none;">
<nav class="settings-breadcrumb">
Einstellungen Synchronisation <span class="settings-breadcrumb__current">Kontakte</span>
</nav>
<header class="settings-page-header">
<h1 class="settings-page-header__title">Kontakte-Synchronisation</h1>
<p class="settings-page-header__description">
Verbinde mehrere CardDAV-Konten und synchronisiere deine Kontakte mit iCloud, Nextcloud und anderen Diensten.
</p>
</header>
<div class="settings-empty-state">
<span style="font-size: 48px;">📇</span>
<h3 class="settings-empty-state__title">Noch keine CardDAV-Konten</h3>
<p class="settings-empty-state__description">
Füge dein erstes CardDAV-Konto hinzu, um Kontakte zu synchronisieren.
</p>
<button class="btn">+ CardDAV-Konto hinzufügen</button>
</div>
</div>
</main>
</div>
</body>
</html>
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "تمت استعادة قاعدة البيانات. جارٍ إعادة التحميل...",
"backupCliTitle": "استعادة CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "يمكنك أيضًا إنشاء نسخة مباشرة عبر Docker Compose:"
"backupCliBackupHint": "يمكنك أيضًا إنشاء نسخة مباشرة عبر Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
+46 -2
View File
@@ -758,14 +758,26 @@
},
"settings": {
"title": "Einstellungen",
"navigationLabel": "Einstellungsnavigation",
"breadcrumbLabel": "Pfad",
"sectionPersonal": "Persönlich",
"sectionModulesNav": "Module",
"sectionSync": "Synchronisation",
"sectionAdmin": "Administration",
"tabGeneral": "Allgemein",
"tabMeals": "Mahlzeiten",
"tabBudget": "Budget",
"tabShopping": "Einkauf",
"tabCalendar": "Kalender",
"tabFamily": "Familienverwaltung",
"tabSync": "Synchronisation",
"tabSyncCalendar": "Kalender",
"tabSyncContacts": "Kontakte",
"sectionContactSync": "Kontakt-Synchronisation",
"cardavTitle": "CardDAV Kontakte",
"tabFamily": "Familie",
"tabApiTokens": "API-Tokens",
"tabAccount": "Konto",
"tabBackup": "Backup",
"tabsAriaLabel": "Einstellungsbereiche",
"sectionDesign": "Design",
"sectionAppName": "Anwendungsname",
@@ -1015,7 +1027,39 @@
"calendarDisabled": "Kalender deaktiviert",
"calendarsRefreshed": "Kalender aktualisiert",
"deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.",
"lastSync": "Zuletzt synchronisiert"
"lastSync": "Zuletzt synchronisiert",
"cardavTitle": "CardDAV Kontakte",
"cardavDescription": "Verbinde mehrere CardDAV-Konten (iCloud, Nextcloud, Radicale, etc.) und synchronisiere deine Kontakte.",
"cardavAddAccount": "CardDAV-Konto hinzufügen",
"cardavEmptyState": "Noch keine CardDAV-Konten verbunden. Füge dein erstes Konto hinzu, um Kontakte zu synchronisieren.",
"cardavNameLabel": "Kontoname",
"cardavNamePlaceholder": "z.B. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server-URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "Die Basis-URL deines CardDAV-Servers",
"cardavUsernameLabel": "Benutzername",
"cardavPasswordLabel": "Passwort",
"cardavPasswordHint": "Für iCloud: App-spezifisches Passwort von appleid.apple.com verwenden",
"cardavAccountAdded": "CardDAV-Konto erfolgreich hinzugefügt",
"cardavAccountDeleted": "CardDAV-Konto entfernt",
"cardavSyncSuccess": "CardDAV-Synchronisation erfolgreich",
"cardavSyncFailed": "CardDAV-Synchronisation fehlgeschlagen",
"cardavConnectionFailed": "Verbindung zum CardDAV-Server fehlgeschlagen",
"cardavAddressbooksToggle": "Adressbücher anzeigen/ausblenden",
"cardavRefreshAddressbooks": "Adressbücher aktualisieren",
"addressbookEnabled": "Adressbuch aktiviert",
"addressbookDisabled": "Adressbuch deaktiviert",
"addressbooksRefreshed": "Adressbücher aktualisiert",
"deleteCardDAVAccountConfirm": "CardDAV-Konto wirklich löschen? Alle synchronisierten Kontakte bleiben erhalten, verlieren aber die CardDAV-Verknüpfung.",
"statusSynced": "Synchronisiert",
"statusError": "Fehler",
"statusSyncing": "Synchronisiert…",
"statusNeverSynced": "Noch nie synchronisiert",
"syncedAgo": "vor {{time}}",
"helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.",
"helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.",
"emptyStateAddFirst": "Füge dein erstes Konto hinzu",
"emptyStateNoAccounts": "Noch keine Konten verbunden"
},
"login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Η βάση επαναφέρθηκε. Επαναφόρτωση...",
"backupCliTitle": "Επαναφορά CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Μπορείτε επίσης να δημιουργήσετε αντίγραφο απευθείας με Docker Compose:"
"backupCliBackupHint": "Μπορείτε επίσης να δημιουργήσετε αντίγραφο απευθείας με Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
+26 -1
View File
@@ -760,7 +760,9 @@
"tabFamily": "Family Management",
"tabApiTokens": "API Tokens",
"tabAccount": "Account",
"tabSync": "Synchronization",
"tabsAriaLabel": "Settings sections",
"sectionContactSync": "Contact Synchronization",
"sectionDesign": "Appearance",
"sectionAppName": "Application name",
"sectionModules": "Modules",
@@ -1009,7 +1011,30 @@
"calendarDisabled": "Calendar disabled",
"calendarsRefreshed": "Calendars refreshed",
"deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.",
"lastSync": "Last synced"
"lastSync": "Last synced",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Base de datos restaurada. Recargando...",
"backupCliTitle": "Restauración por CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "También puedes crear una copia directamente con Docker Compose:"
"backupCliBackupHint": "También puedes crear una copia directamente con Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Planificación familiar. Segura. Privada. Código abierto.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Base restaurée. Rechargement...",
"backupCliTitle": "Restauration CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Tu peux aussi créer une sauvegarde directement avec Docker Compose :"
"backupCliBackupHint": "Tu peux aussi créer une sauvegarde directement avec Docker Compose :",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "डेटाबेस पुनर्स्थापित हुआ। फिर से लोड हो रहा है...",
"backupCliTitle": "CLI / Docker Compose पुनर्स्थापना",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "आप Docker Compose से सीधे बैकअप भी बना सकते हैं:"
"backupCliBackupHint": "आप Docker Compose से सीधे बैकअप भी बना सकते हैं:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Database ripristinato. Ricaricamento...",
"backupCliTitle": "Ripristino CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Puoi anche creare un backup direttamente con Docker Compose:"
"backupCliBackupHint": "Puoi anche creare un backup direttamente con Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "データベースを復元しました。再読み込み中...",
"backupCliTitle": "CLI / Docker Compose 復元",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Docker Compose から直接バックアップを作成することもできます:"
"backupCliBackupHint": "Docker Compose から直接バックアップを作成することもできます:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "家族計画。安全。プライバシー重視。オープンソース。",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Banco de dados restaurado. Recarregando...",
"backupCliTitle": "Restauração via CLI / Docker Compose",
"backupCliHint": "Para restaurações operacionais, pare a aplicação, monte o backup em um container temporário e substitua o arquivo do banco de dados.",
"backupCliBackupHint": "Você também pode criar um backup diretamente pelo Docker Compose:"
"backupCliBackupHint": "Você também pode criar um backup diretamente pelo Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "База данных восстановлена. Перезагрузка...",
"backupCliTitle": "Восстановление CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Также можно создать копию напрямую через Docker Compose:"
"backupCliBackupHint": "Также можно создать копию напрямую через Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Databasen återställd. Laddar om...",
"backupCliTitle": "CLI / Docker Compose-återställning",
"backupCliHint": "För driftåterställningar, stoppa appen, montera säkerhetskopian i en tillfällig container och byt ut databasfilen.",
"backupCliBackupHint": "Du kan också skapa en backup direkt med Docker Compose:"
"backupCliBackupHint": "Du kan också skapa en backup direkt med Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Veritabanı geri yüklendi. Yeniden yükleniyor...",
"backupCliTitle": "CLI / Docker Compose geri yükleme",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Docker Compose ile doğrudan yedek de oluşturabilirsiniz:"
"backupCliBackupHint": "Docker Compose ile doğrudan yedek de oluşturabilirsiniz:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "Базу даних відновлено. Перезавантаження...",
"backupCliTitle": "Відновлення CLI / Docker Compose",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "Також можна створити копію безпосередньо через Docker Compose:"
"backupCliBackupHint": "Також можна створити копію безпосередньо через Docker Compose:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
+26 -1
View File
@@ -952,7 +952,32 @@
"backupRestoredToast": "数据库已恢复。正在重新加载...",
"backupCliTitle": "CLI / Docker Compose 恢复",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "也可以直接通过 Docker Compose 创建备份:"
"backupCliBackupHint": "也可以直接通过 Docker Compose 创建备份:",
"tabSync": "Synchronization",
"sectionContactSync": "Contact Synchronization",
"cardavTitle": "CardDAV Contacts",
"cardavDescription": "Connect multiple CardDAV accounts (iCloud, Nextcloud, Radicale, etc.) and sync your contacts.",
"cardavAddAccount": "Add CardDAV Account",
"cardavEmptyState": "No CardDAV accounts connected yet. Add your first account to sync contacts.",
"cardavNameLabel": "Account name",
"cardavNamePlaceholder": "e.g. iCloud, Nextcloud",
"cardavUrlLabel": "CardDAV Server URL",
"cardavUrlPlaceholder": "https://contacts.icloud.com",
"cardavUrlHint": "The base URL of your CardDAV server",
"cardavUsernameLabel": "Username",
"cardavPasswordLabel": "Password",
"cardavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com",
"cardavAccountAdded": "CardDAV account added successfully",
"cardavAccountDeleted": "CardDAV account removed",
"cardavSyncSuccess": "CardDAV sync successful",
"cardavSyncFailed": "CardDAV sync failed",
"cardavConnectionFailed": "Connection to CardDAV server failed",
"cardavAddressbooksToggle": "Show/hide addressbooks",
"cardavRefreshAddressbooks": "Refresh addressbooks",
"addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed",
"deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link."
},
"login": {
"tagline": "家庭规划。安全。注重隐私。开源。",
+230 -14
View File
@@ -1,13 +1,14 @@
/**
* Modul: Einstellungen (Settings)
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
* Abhängigkeiten: /api.js
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Kontakte-Sync, Familienmitglieder
* Abhängigkeiten: /api.js, /utils/settings-nav.js
*/
import { api, auth } from '/api.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js';
import { esc } from '/utils/html.js';
import { renderSettingsSidebar, renderBreadcrumb, getLastActivePage, setActivePage, findSectionAndPage } from '/utils/settings-nav.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'];
@@ -244,14 +245,14 @@ export async function render(container, { user }) {
: t('settings.notConnected');
const allowedTabs = [
'general', 'meals', 'budget', 'shopping', 'calendar',
'general', 'meals', 'budget', 'shopping', 'sync',
...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
'account',
...(user?.role === 'admin' ? ['backup'] : []),
];
const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
const activeTab = (syncOk || syncErr)
? 'calendar'
? 'sync'
: (allowedTabs.includes(storedTab) ? storedTab : 'general');
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
@@ -272,11 +273,11 @@ export async function render(container, { user }) {
<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('calendar')}" role="tab" data-tab="calendar" aria-selected="${btnAria('calendar')}">${t('settings.tabCalendar')}</button>
${user?.role === 'admin' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}">${t('settings.tabFamily')}</button>` : ''}
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}">${t('settings.tabApiTokens')}</button>` : ''}
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}">${t('settings.tabAccount')}</button>
${user?.role === 'admin' ? `<button class="${btnClass('backup')}" role="tab" data-tab="backup" aria-selected="${btnAria('backup')}">${t('settings.tabBackup')}</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) -->
@@ -453,8 +454,9 @@ export async function render(container, { user }) {
</section>
</div>
<!-- Panel: Kalender -->
<div class="settings-tab-panel" data-panel="calendar" role="tabpanel"${panelHidden('calendar')}>
<!-- Panel: Synchronisation -->
<div class="settings-tab-panel" data-panel="sync" role="tabpanel"${panelHidden('sync')}>
<!-- Sektion: Kalender-Synchronisation -->
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionCalendarSync')}</h2>
@@ -586,6 +588,27 @@ export async function render(container, { user }) {
</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>
</div>
${user?.role === 'admin' ? `
@@ -1328,9 +1351,130 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
}
}
// Load CalDAV accounts on page load
if (user?.role === 'admin') {
loadCalDAVAccounts(container);
// CardDAV-Konten laden
async function loadCardDAVAccounts(container) {
const listEl = container.querySelector('#cardav-accounts-list');
const emptyEl = container.querySelector('#cardav-empty-state');
if (!listEl || !emptyEl) return;
try {
const accountsRes = await api.get('/contacts/cardav/accounts');
const accounts = accountsRes.data || [];
if (accounts.length === 0) {
listEl.replaceChildren();
emptyEl.style.display = '';
return;
}
emptyEl.style.display = 'none';
listEl.replaceChildren();
for (const account of accounts) {
const addressbooksRes = await api.get(`/contacts/cardav/accounts/${account.id}/addressbooks`);
const addressbooks = addressbooksRes.data || [];
const accountCard = document.createElement('div');
accountCard.className = 'caldav-account-item';
accountCard.insertAdjacentHTML('beforeend', `
<div class="caldav-account-header">
<h4>${esc(account.name)}</h4>
<div class="caldav-account-meta">
<span>${esc(account.cardav_url)}</span>
${account.last_sync ? `<span>${t('settings.lastSync')}: ${formatDateTime(account.last_sync)}</span>` : ''}
</div>
</div>
<details class="caldav-calendars-details">
<summary class="caldav-calendars-summary">
${t('settings.cardavAddressbooksToggle')} (${addressbooks.length})
</summary>
<div class="caldav-calendars-list">
${addressbooks.map((ab) => `
<label class="caldav-calendar-item">
<input type="checkbox" class="caldav-calendar-checkbox cardav-addressbook-checkbox"
data-account-id="${account.id}"
data-addressbook-url="${esc(ab.url)}"
${ab.enabled ? 'checked' : ''}>
<span class="caldav-calendar-name">${esc(ab.display_name || ab.url)}</span>
</label>
`).join('')}
</div>
</details>
<div class="caldav-account-actions">
<button class="btn btn--secondary btn--sm" data-cardav-sync="${account.id}">${t('settings.syncNow')}</button>
<button class="btn btn--secondary btn--sm" data-cardav-refresh="${account.id}">${t('settings.cardavRefreshAddressbooks')}</button>
<button class="btn btn--danger-outline btn--sm" data-cardav-delete="${account.id}">${t('settings.disconnect')}</button>
</div>
`);
// Addressbook toggle
accountCard.querySelectorAll('.cardav-addressbook-checkbox').forEach((checkbox) => {
checkbox.addEventListener('change', async () => {
const accountId = parseInt(checkbox.dataset.accountId, 10);
const addressbookUrl = checkbox.dataset.addressbookUrl;
const enabled = checkbox.checked;
try {
await api.post(`/contacts/cardav/accounts/${accountId}/addressbooks/toggle`, {
addressbookUrl,
enabled,
});
window.oikos?.showToast(enabled ? t('settings.addressbookEnabled') : t('settings.addressbookDisabled'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'error');
checkbox.checked = !enabled;
}
});
});
// Sync button
const syncBtn = accountCard.querySelector(`[data-cardav-sync="${account.id}"]`);
if (syncBtn) {
syncBtn.addEventListener('click', async () => {
try {
await api.post(`/contacts/cardav/accounts/${account.id}/sync`);
window.oikos?.showToast(t('settings.cardavSyncSuccess'), 'success');
await loadCardDAVAccounts(container);
} catch (err) {
window.oikos?.showToast(t('settings.cardavSyncFailed'), 'error');
}
});
}
// Refresh button
const refreshBtn = accountCard.querySelector(`[data-cardav-refresh="${account.id}"]`);
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
try {
await api.post(`/contacts/cardav/accounts/${account.id}/addressbooks/refresh`);
window.oikos?.showToast(t('settings.addressbooksRefreshed'), 'success');
await loadCardDAVAccounts(container);
} catch (err) {
window.oikos?.showToast(err.message, 'error');
}
});
}
// Delete button
const deleteBtn = accountCard.querySelector(`[data-cardav-delete="${account.id}"]`);
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
const confirmed = await confirmModal(t('settings.deleteCardDAVAccountConfirm'));
if (!confirmed) return;
try {
await api.delete(`/contacts/cardav/accounts/${account.id}`);
window.oikos?.showToast(t('settings.cardavAccountDeleted'), 'success');
await loadCardDAVAccounts(container);
} catch (err) {
window.oikos?.showToast(err.message, 'error');
}
});
}
listEl.appendChild(accountCard);
}
} catch (err) {
console.error('Failed to load CardDAV accounts:', err);
}
}
// CalDAV add account button
@@ -1398,6 +1542,70 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
});
}
// CardDAV add account button
const cardavAddBtn = container.querySelector('#cardav-add-account-btn');
if (cardavAddBtn) {
cardavAddBtn.addEventListener('click', () => {
openModal({
title: t('settings.cardavAddAccount'),
size: 'sm',
content: `
<form id="cardav-add-form" novalidate autocomplete="off">
<div class="form-group">
<label class="form-label" for="cardav-name">${t('settings.cardavNameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
<input class="form-input" type="text" id="cardav-name" required
placeholder="${t('settings.cardavNamePlaceholder')}" maxlength="100" />
</div>
<div class="form-group">
<label class="form-label" for="cardav-url">${t('settings.cardavUrlLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
<input class="form-input" type="url" id="cardav-url" required
placeholder="${t('settings.cardavUrlPlaceholder')}" />
<small class="form-hint">${t('settings.cardavUrlHint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="cardav-username">${t('settings.cardavUsernameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
<input class="form-input" type="text" id="cardav-username" required autocomplete="username" />
</div>
<div class="form-group">
<label class="form-label" for="cardav-password">${t('settings.cardavPasswordLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
<input class="form-input" type="password" id="cardav-password" required autocomplete="current-password" />
<small class="form-hint">${t('settings.cardavPasswordHint')}</small>
</div>
<div id="cardav-add-error" class="form-error" hidden></div>
</form>
`,
onSave: async (panel) => {
const errorEl = panel.querySelector('#cardav-add-error');
errorEl.hidden = true;
const name = panel.querySelector('#cardav-name').value.trim();
const cardavUrl = panel.querySelector('#cardav-url').value.trim();
const username = panel.querySelector('#cardav-username').value.trim();
const password = panel.querySelector('#cardav-password').value;
if (!name || !cardavUrl || !username || !password) {
showError(errorEl, t('common.allFieldsRequired'));
return;
}
try {
await api.post('/contacts/cardav/accounts', {
name,
cardavUrl,
username,
password,
});
closeModal({ force: true });
window.oikos?.showToast(t('settings.cardavAccountAdded'), 'success');
await loadCardDAVAccounts(container);
} catch (err) {
showError(errorEl, err.message);
}
},
});
});
}
// Mitglied hinzufügen (Admin)
const addMemberBtn = container.querySelector('#add-member-btn');
if (addMemberBtn) {
@@ -2356,6 +2564,14 @@ function bindIcsEvents(container, user, initialSubs) {
}
});
}
// Initial Load: CalDAV & CardDAV Accounts
if (container.querySelector('#caldav-accounts-list')) {
loadCalDAVAccounts(container);
}
if (container.querySelector('#cardav-accounts-list')) {
loadCardDAVAccounts(container);
}
}
function initials(name) {
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
/**
* Modul: Settings Navigation
* Zweck: Zweistufige Sidebar-Navigation für Settings
* Abhängigkeiten: tokens.css, layout.css
*/
/* --------------------------------------------------------
Layout Grid
-------------------------------------------------------- */
.settings-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 0;
min-height: calc(100vh - var(--nav-bottom-height, 0px));
background: var(--color-bg);
}
@media (max-width: 768px) {
.settings-layout {
grid-template-columns: 1fr;
}
.settings-sidebar {
display: none;
}
/* Mobile: Zeige horizontale Tabs statt Sidebar */
.settings-layout--mobile-tabs {
display: block;
}
}
/* --------------------------------------------------------
Sidebar
-------------------------------------------------------- */
.settings-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-4) 0;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.settings-sidebar::-webkit-scrollbar {
width: 6px;
}
.settings-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.settings-sidebar::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.settings-sidebar::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* --------------------------------------------------------
Sidebar Section
-------------------------------------------------------- */
.settings-sidebar-section {
margin-bottom: var(--space-6);
}
.settings-sidebar-section:last-child {
margin-bottom: 0;
}
.settings-sidebar-section__header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
margin-bottom: var(--space-2);
color: var(--color-text-tertiary);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
.settings-sidebar-section__icon {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.7;
}
.settings-sidebar-section__label {
line-height: 1;
}
/* Active Section */
.settings-sidebar-section--active .settings-sidebar-section__header {
color: var(--color-accent);
}
.settings-sidebar-section--active .settings-sidebar-section__icon {
opacity: 1;
}
/* --------------------------------------------------------
Sidebar Pages
-------------------------------------------------------- */
.settings-sidebar-pages {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.settings-sidebar-page {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
margin: 0 var(--space-2);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
font-family: inherit;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast), color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
min-height: var(--target-base);
}
.settings-sidebar-page:hover {
background: var(--color-surface-2);
color: var(--color-text-primary);
}
.settings-sidebar-page--active {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
font-weight: var(--font-weight-semibold);
}
.settings-sidebar-page--active:hover {
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.settings-sidebar-page__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.settings-sidebar-page__label {
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --------------------------------------------------------
Content Area
-------------------------------------------------------- */
.settings-content {
padding: var(--space-6) var(--space-6) var(--space-8);
max-width: 900px;
min-width: 0;
}
@media (max-width: 768px) {
.settings-content {
padding: var(--space-4);
}
}
/* --------------------------------------------------------
Breadcrumb
-------------------------------------------------------- */
.settings-breadcrumb {
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.settings-breadcrumb__list {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.settings-breadcrumb__item {
font-size: var(--text-sm);
color: var(--color-text-secondary);
line-height: 1;
}
.settings-breadcrumb__item--current {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.settings-breadcrumb__separator {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1;
user-select: none;
}
/* --------------------------------------------------------
Page Header (in Content Area)
-------------------------------------------------------- */
.settings-page-header {
margin-bottom: var(--space-6);
}
.settings-page-header__title {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin: 0 0 var(--space-2);
letter-spacing: -0.02em;
}
.settings-page-header__description {
font-size: var(--text-base);
color: var(--color-text-secondary);
line-height: 1.6;
margin: 0;
}
/* --------------------------------------------------------
Mobile Tabs (Fallback für kleine Screens)
-------------------------------------------------------- */
@media (max-width: 768px) {
.settings-mobile-tabs {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--color-bg);
}
.settings-mobile-tabs::-webkit-scrollbar {
display: none;
}
.settings-mobile-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;
}
.settings-mobile-tab--active {
background-color: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
}
.settings-mobile-tab__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
/* --------------------------------------------------------
Help Tooltip
-------------------------------------------------------- */
.settings-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: var(--space-1);
color: var(--color-text-tertiary);
cursor: help;
position: relative;
vertical-align: middle;
}
.settings-help-icon:hover {
color: var(--color-accent);
}
.settings-help-icon svg,
.settings-help-icon i {
width: 14px;
height: 14px;
}
/* Tooltip */
.settings-help-tooltip {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 50%;
transform: translateX(-50%);
padding: var(--space-2) var(--space-3);
max-width: 240px;
background: var(--color-text-primary);
color: var(--color-bg);
font-size: var(--text-xs);
line-height: 1.4;
border-radius: var(--radius-sm);
white-space: normal;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
z-index: var(--z-tooltip);
box-shadow: var(--shadow-lg);
}
.settings-help-icon:hover .settings-help-tooltip {
opacity: 1;
}
/* Tooltip Arrow */
.settings-help-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--color-text-primary);
}
/* --------------------------------------------------------
Status Badge (für Sync-Status)
-------------------------------------------------------- */
.settings-status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.settings-status-badge--success {
background: var(--color-success-light);
color: var(--color-success);
}
.settings-status-badge--error {
background: var(--color-danger-light);
color: var(--color-danger);
}
.settings-status-badge--syncing {
background: var(--color-accent-light);
color: var(--color-accent);
}
.settings-status-badge__icon {
width: 12px;
height: 12px;
}
/* Spinning animation für Sync-Icon */
.settings-status-badge--syncing .settings-status-badge__icon {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* --------------------------------------------------------
Empty State (für Onboarding)
-------------------------------------------------------- */
.settings-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-6);
text-align: center;
border-radius: var(--radius-lg);
background: var(--color-surface-2);
border: 2px dashed var(--color-border);
}
.settings-empty-state__icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
color: var(--color-text-tertiary);
opacity: 0.5;
}
.settings-empty-state__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--space-2);
}
.settings-empty-state__description {
font-size: var(--text-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin: 0 0 var(--space-4);
max-width: 400px;
}
.settings-empty-state__action {
/* Button wird schon durch .btn gestyled */
}
+199
View File
@@ -91,6 +91,43 @@
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);
}
/* --------------------------------------------------------
Sections
-------------------------------------------------------- */
@@ -568,6 +605,168 @@
}
}
/* --------------------------------------------------------
Breadcrumb Navigation (UX-Verbesserung 1)
-------------------------------------------------------- */
.settings-breadcrumb {
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.settings-breadcrumb__current {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
/* --------------------------------------------------------
Inline Help Icons mit Tooltips (UX-Verbesserung 2)
-------------------------------------------------------- */
.settings-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: var(--space-1);
color: var(--color-text-tertiary);
cursor: help;
position: relative;
vertical-align: middle;
}
.settings-help-icon:hover {
color: var(--color-accent);
}
.settings-help-icon svg,
.settings-help-icon i {
width: 14px;
height: 14px;
}
.settings-help-tooltip {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 50%;
transform: translateX(-50%);
padding: var(--space-2) var(--space-3);
max-width: 240px;
background: var(--color-text-primary);
color: var(--color-bg);
font-size: var(--text-xs);
line-height: 1.4;
border-radius: var(--radius-sm);
white-space: normal;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
z-index: var(--z-tooltip);
box-shadow: var(--shadow-lg);
}
.settings-help-icon:hover .settings-help-tooltip {
opacity: 1;
}
.settings-help-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--color-text-primary);
}
/* --------------------------------------------------------
Status Badges (UX-Verbesserung 3)
-------------------------------------------------------- */
.settings-status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.settings-status-badge--success {
background: var(--color-success-light);
color: var(--color-success);
}
.settings-status-badge--error {
background: var(--color-danger-light);
color: var(--color-danger);
}
.settings-status-badge--syncing {
background: var(--color-accent-light);
color: var(--color-accent);
}
.settings-status-badge__icon {
width: 12px;
height: 12px;
}
.settings-status-badge--syncing .settings-status-badge__icon {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* --------------------------------------------------------
Enhanced Empty State (UX-Verbesserung 4)
-------------------------------------------------------- */
.settings-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-6);
text-align: center;
border-radius: var(--radius-lg);
background: var(--color-surface-2);
border: 2px dashed var(--color-border);
margin: var(--space-4) 0;
}
.settings-empty-state__icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
color: var(--color-text-tertiary);
opacity: 0.5;
}
.settings-empty-state__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--space-2);
}
.settings-empty-state__description {
font-size: var(--text-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin: 0 0 var(--space-4);
max-width: 400px;
}
/* --------------------------------------------------------
Theme-Toggle
-------------------------------------------------------- */
+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);
}