diff --git a/docs/designs/2026-05-04-settings-sidebar-demo.html b/docs/designs/2026-05-04-settings-sidebar-demo.html
new file mode 100644
index 0000000..0771496
--- /dev/null
+++ b/docs/designs/2026-05-04-settings-sidebar-demo.html
@@ -0,0 +1,349 @@
+
+
+
+
+
+ Settings Sidebar Navigation - Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Design
+
+ Wähle zwischen hellem, dunklem oder System-Modus.
+
+
+
+
+
Sprache
+
+ Ändere die Anzeigesprache der Anwendung.
+
+
+
+
+
+
+
+
+
+
+
📇
+
Noch keine CardDAV-Konten
+
+ Füge dein erstes CardDAV-Konto hinzu, um Kontakte zu synchronisieren.
+
+
+
+
+
+
+
+
diff --git a/public/locales/ar.json b/public/locales/ar.json
index fd472ae..f170b5b 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -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": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
@@ -1191,4 +1216,4 @@
"new": "إنشاء إدخال جديد",
"search": "فتح البحث"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/de.json b/public/locales/de.json
index 29466b8..358976a 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -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.",
diff --git a/public/locales/el.json b/public/locales/el.json
index 84b67a6..64da05e 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -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": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
@@ -1191,4 +1216,4 @@
"new": "Δημιουργία νέας εγγραφής",
"search": "Άνοιγμα αναζήτησης"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/en.json b/public/locales/en.json
index 44c0295..ade2cff 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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.",
diff --git a/public/locales/es.json b/public/locales/es.json
index 30fee5a..267c851 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"new": "Crear nueva entrada",
"search": "Abrir búsqueda"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/fr.json b/public/locales/fr.json
index 435314a..a54ade8 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"new": "Créer une nouvelle entrée",
"search": "Ouvrir la recherche"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/hi.json b/public/locales/hi.json
index dec8486..331cd74 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -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": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
@@ -1191,4 +1216,4 @@
"new": "नई प्रविष्टि बनाएं",
"search": "खोज खोलें"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/it.json b/public/locales/it.json
index a24fde4..1717950 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"new": "Crea nuova voce",
"search": "Apri ricerca"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/ja.json b/public/locales/ja.json
index b7cff32..e343ec1 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -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": "家族計画。安全。プライバシー重視。オープンソース。",
@@ -1191,4 +1216,4 @@
"new": "新規エントリを作成",
"search": "検索を開く"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/pt.json b/public/locales/pt.json
index 8ee1974..b12c0d2 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"new": "Criar nova entrada",
"search": "Abrir pesquisa"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 08ba17e..10fc587 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -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": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
@@ -1191,4 +1216,4 @@
"new": "Создать новую запись",
"search": "Открыть поиск"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/sv.json b/public/locales/sv.json
index 4a030b4..3e4f571 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"goShop": "Inköpslista",
"goNotes": "Anteckningar"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/tr.json b/public/locales/tr.json
index 5581555..f23f427 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -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.",
@@ -1191,4 +1216,4 @@
"new": "Yeni giriş oluştur",
"search": "Aramayı aç"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/uk.json b/public/locales/uk.json
index c6a3fd7..a3c1471 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -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": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
@@ -1191,4 +1216,4 @@
"new": "Створити новий запис",
"search": "Відкрити пошук"
}
-}
+}
\ No newline at end of file
diff --git a/public/locales/zh.json b/public/locales/zh.json
index 42ba68c..5e629f9 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -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": "家庭规划。安全。注重隐私。开源。",
@@ -1191,4 +1216,4 @@
"new": "创建新条目",
"search": "打开搜索"
}
-}
+}
\ No newline at end of file
diff --git a/public/pages/settings.js b/public/pages/settings.js
index f85921e..d7995d1 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -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 }) {
-
- ${user?.role === 'admin' ? `` : ''}
- ${user?.role === 'admin' ? `` : ''}
-
- ${user?.role === 'admin' ? `` : ''}
+
+
+ ${user?.role === 'admin' ? `` : ''}
+ ${user?.role === 'admin' ? `` : ''}
+ ${user?.role === 'admin' ? `` : ''}
@@ -453,8 +454,9 @@ export async function render(container, { user }) {
-
-
+
+
+
${t('settings.sectionCalendarSync')}
@@ -586,6 +588,27 @@ export async function render(container, { user }) {
+
+
+
+ ${t('settings.sectionContactSync')}
+
+
+
${t('settings.cardavTitle')}
+
${t('settings.cardavDescription')}
+
+
+
+
${t('settings.cardavEmptyState')}
+
+
+ ${user?.role === 'admin' ? `
+
+ ` : ''}
+
+
${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', `
+
+
+
+ ${t('settings.cardavAddressbooksToggle')} (${addressbooks.length})
+
+
+ ${addressbooks.map((ab) => `
+
+ `).join('')}
+
+
+
+
+
+
+
+ `);
+
+ // 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: `
+
+ `,
+ 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) {
diff --git a/public/pages/settings.js.backup b/public/pages/settings.js.backup
new file mode 100644
index 0000000..f85921e
--- /dev/null
+++ b/public/pages/settings.js.backup
@@ -0,0 +1,2383 @@
+/**
+ * Modul: Einstellungen (Settings)
+ * Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
+ * Abhängigkeiten: /api.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 '/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 SETTINGS_TAB_KEY = 'oikos:settings:tab';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+const DEFAULT_APP_NAME = 'Oikos';
+const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
+const MAX_AVATAR_DATA_LENGTH = 768 * 1024;
+
+const CATEGORY_I18N = {
+ 'Obst & Gemüse': 'shopping.catFruitVeg',
+ 'Backwaren': 'shopping.catBakery',
+ 'Milchprodukte': 'shopping.catDairy',
+ 'Fleisch & Fisch': 'shopping.catMeatFish',
+ 'Tiefkühl': 'shopping.catFrozen',
+ 'Getränke': 'shopping.catDrinks',
+ 'Haushalt': 'shopping.catHousehold',
+ 'Drogerie': 'shopping.catDrugstore',
+ 'Sonstiges': 'shopping.catMisc',
+};
+function catLabel(name) {
+ const key = CATEGORY_I18N[name];
+ return key ? t(key) : name;
+}
+
+function buildCurrencyOptions(selected) {
+ const display = typeof Intl.DisplayNames !== 'undefined'
+ ? new Intl.DisplayNames([document.documentElement.lang || 'en'], { type: 'currency' })
+ : null;
+ return SUPPORTED_CURRENCIES
+ .map((code) => {
+ const label = display ? `${code} - ${display.of(code)}` : code;
+ const sel = code === selected ? ' selected' : '';
+ return ``;
+ })
+ .join('');
+}
+
+function familyRoleLabel(role) {
+ return t(`settings.familyRole${String(role || 'other').replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase())}`);
+}
+
+function buildFamilyRoleOptions(selected = 'other') {
+ return FAMILY_ROLES.map((role) => `
+
+ `).join('');
+}
+
+function maskDateInputValue(value) {
+ const digits = String(value || '').replace(/\D/g, '').slice(0, 8);
+ if (!digits) return '';
+
+ if (getDateFormat() === 'ymd') {
+ return [
+ digits.slice(0, 4),
+ digits.slice(4, 6),
+ digits.slice(6, 8),
+ ].filter(Boolean).join('-');
+ }
+
+ return [
+ digits.slice(0, 2),
+ digits.slice(2, 4),
+ digits.slice(4, 8),
+ ].filter(Boolean).join('/');
+}
+
+function bindSettingsDateInputs(root) {
+ root.querySelectorAll('.js-date-input').forEach((input) => {
+ input.addEventListener('input', () => {
+ input.value = maskDateInputValue(input.value);
+ });
+ input.addEventListener('blur', () => {
+ const parsed = parseDateInput(input.value);
+ if (parsed) input.value = formatDateInput(parsed);
+ });
+ });
+}
+
+function avatarHtml(user, className = 'settings-avatar') {
+ const safeName = esc(user?.display_name || '');
+ const fallback = esc(initials(user?.display_name || ''));
+ const bg = esc(user?.avatar_color || '#007AFF');
+ return `
+
+ ${user?.avatar_data ? `
})
` : fallback}
+
+ `;
+}
+
+function avatarEditorHtml(user, prefix) {
+ return `
+
+
+
+
+
+
+
+
+ `;
+}
+
+function setAvatarPreview(container, selector, user) {
+ const preview = container.querySelector(selector);
+ if (!preview) return;
+ preview.replaceChildren();
+ preview.insertAdjacentHTML('beforeend', avatarHtml(user, 'settings-avatar settings-avatar--lg'));
+}
+
+function bindAvatarPicker(container, prefix) {
+ const fileInput = container.querySelector(`#${prefix}-avatar-file`);
+ const pickers = [
+ container.querySelector(`#${prefix}-avatar-preview`),
+ container.querySelector(`#${prefix}-avatar-edit`),
+ ];
+ pickers.forEach((picker) => {
+ picker?.addEventListener('click', () => fileInput?.click());
+ });
+}
+
+function readImageAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ if (!file) return resolve(undefined);
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
+ return reject(new Error(t('settings.profilePictureTypeError')));
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ return reject(new Error(t('settings.profilePictureFileTooLarge')));
+ }
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ const img = new Image();
+ img.onload = () => {
+ try {
+ const maxSize = 512;
+ const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
+ const width = Math.max(1, Math.round(img.width * scale));
+ const height = Math.max(1, Math.round(img.height * scale));
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, width, height);
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.86);
+ if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) {
+ reject(new Error(t('settings.profilePictureTooLarge')));
+ } else {
+ resolve(dataUrl);
+ }
+ } catch (err) {
+ reject(err);
+ }
+ };
+ img.onerror = () => reject(new Error(t('settings.profilePictureReadError')));
+ img.src = reader.result;
+ };
+ reader.onerror = () => reject(new Error(t('settings.profilePictureReadError')));
+ reader.readAsDataURL(file);
+ });
+}
+
+/**
+ * @param {HTMLElement} container
+ * @param {{ user: object }} context
+ */
+export async function render(container, { user }) {
+ try {
+ const me = await auth.me();
+ if (me?.user && user) Object.assign(user, me.user);
+ else if (me?.user) user = me.user;
+ } catch {
+ // Non-critical: render with the user object provided by the router.
+ }
+
+ // URL-Parameter auswerten (z.B. nach OAuth-Callback)
+ const params = new URLSearchParams(location.search);
+ const syncOk = params.get('sync_ok');
+ const syncErr = params.get('sync_error');
+
+ // State für Familienmitglieder + Sync-Status
+ let users = [];
+ let googleStatus = { configured: false, connected: false, lastSync: null };
+ let appleStatus = { configured: false, lastSync: null };
+ let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [] };
+ let categories = [];
+ let icsSubscriptions = [];
+ let apiTokens = [];
+
+ try {
+ const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes, apiTokensRes] = await Promise.allSettled([
+ user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
+ api.get('/calendar/google/status'),
+ api.get('/calendar/apple/status'),
+ api.get('/preferences'),
+ api.get('/shopping/categories'),
+ api.get('/calendar/subscriptions'),
+ user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }),
+ ]);
+ if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
+ if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
+ if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
+ if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
+ if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
+ if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
+ if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
+ } catch (_) { /* non-critical */ }
+
+ if (prefs.date_format) {
+ try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
+ }
+ if (prefs.time_format) {
+ try { localStorage.setItem('oikos-time-format', prefs.time_format); } catch (_) {}
+ }
+ if (prefs.app_name) {
+ try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
+ }
+
+ const googleStatusText = googleStatus.connected
+ ? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
+ : googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
+
+ const appleStatusText = appleStatus.connected
+ ? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.connected'))
+ : appleStatus.configured
+ ? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.configured'))
+ : t('settings.notConnected');
+
+ const allowedTabs = [
+ 'general', 'meals', 'budget', 'shopping', 'calendar',
+ ...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
+ 'account',
+ ...(user?.role === 'admin' ? ['backup'] : []),
+ ];
+ const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
+ const activeTab = (syncOk || syncErr)
+ ? 'calendar'
+ : (allowedTabs.includes(storedTab) ? storedTab : 'general');
+
+ 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 = `
+
+
+
+ ${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''}
+ ${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''}
+
+
+
+
+
+
+ ${t('settings.sectionDesign')}
+
+
${t('settings.cardAppearance')}
+
+
+
+
+
+
+
+
+ ${user?.role === 'admin' ? `
+
+ ${t('settings.sectionAppName')}
+
+
${t('settings.appNameTitle')}
+
${t('settings.appNameHint')}
+
+
+
+ ` : ''}
+
+
+ ${t('settings.sectionDate')}
+
+
${t('settings.dateFormatTitle')}
+
${t('settings.dateFormatHint')}
+
+
+
+
+
+
+
+
+ ${t('settings.languageTitle')}
+
+
+
+
+
+ ${user?.role === 'admin' ? `
+
+ ${t('settings.sectionModules')}
+
+
${t('settings.modulesTitle')}
+
${t('settings.modulesHint')}
+
+ ${[
+ ['tasks', 'nav.tasks'],
+ ['calendar', 'nav.calendar'],
+ ['meals', 'nav.meals'],
+ ['recipes', 'nav.recipes'],
+ ['shopping', 'nav.shopping'],
+ ['birthdays', 'nav.birthdays'],
+ ['notes', 'nav.notes'],
+ ['contacts', 'nav.contacts'],
+ ['budget', 'nav.budget'],
+ ['documents', 'nav.documents'],
+ ].map(([slug, labelKey]) => `
+
+ `).join('')}
+
+
+
+ ` : ''}
+
+
+
+
+
+ ${t('settings.sectionMeals')}
+
+
+
+
+
+
+
+ ${t('settings.sectionBudget')}
+
+
${t('settings.currencyLabel')}
+
${t('settings.currencyHint')}
+
+
+
+
+
+
+
+
+ ${t('settings.sectionShopping')}
+
+
${t('settings.shoppingCategoriesLabel')}
+
${t('settings.shoppingCategoriesHint')}
+
+ ${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')}
+
+
+
+
+
+
+
+
+
+ ${t('settings.sectionCalendarSync')}
+
+
+
+
+ ${googleStatus.configured ? `
+
+ ${googleStatus.connected ? `
+
+ ${user?.role === 'admin' ? `
` : ''}
+ ` : `
+ ${user?.role === 'admin' ? `
${t('settings.connectGoogle')}` : `
${t('settings.googleOnlyAdmin')}`}
+ `}
+
+ ` : ''}
+
+
+
+
+
+ ${appleStatus.configured ? `
+
+
+ ${appleStatus.connected && user?.role === 'admin' ? `` : ''}
+
+ ` : user?.role === 'admin' ? `
+
+ ` : `
${t('settings.appleOnlyAdmin')}`}
+
+
+
+
+
${t('settings.caldavTitle')}
+
${t('settings.caldavDescription')}
+
+
+
+
${t('settings.caldavEmptyState')}
+
+
+ ${user?.role === 'admin' ? `
+
+ ` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${user?.role === 'admin' ? `
+
+
+
+ ${t('settings.sectionFamily')}
+
+
+ ${users.map(memberHtml).join('')}
+
+
+
+
+
+
+
+ ` : ''}
+
+ ${user?.role === 'admin' ? `
+
+
+
+ ${t('settings.apiTokensTitle')}
+
+
${t('settings.apiTokensCardTitle')}
+
${t('settings.apiTokensHint')}
+
+ ${apiTokens.map(apiTokenHtml).join('')}
+
+
+
+
+
+ ` : ''}
+
+
+
+
+ ${t('settings.sectionAccount')}
+
+
+
+ ${avatarHtml(user)}
+
+
${esc(user?.display_name)}
+
@${esc(user?.username)}
+
+
+
+
+
+
${t('settings.profilePictureTitle')}
+
+
+
+
+
${t('settings.changePassword')}
+
+
+
+
+
+
+
+
+
+ ${user?.role === 'admin' ? `
+
+
+
+ ${t('settings.sectionBackup')}
+
+
+
+
+
+
+
${t('settings.backupDownloadTitle')}
+
${t('settings.backupDownloadHint')}
+
+
+
+
+
+
+
+
+
+
${t('settings.backupRestoreTitle')}
+
${t('settings.backupRestoreHint')}
+
+
+
+
+
+
${t('settings.backupSchedulerTitle')}
+
${t('settings.backupSchedulerHint')}
+
+
+
+
+
+
+
${t('settings.backupCliTitle')}
+
${t('settings.backupCliHint')}
+
SERVICE=oikos
+BACKUP="$PWD/oikos-backup.db"
+docker compose stop "$SERVICE"
+docker compose run --rm -v "$BACKUP:/tmp/oikos-restore.db:ro" --entrypoint sh "$SERVICE" -c 'set -eu; target="\${DB_PATH:-/data/oikos.db}"; stamp=$(date -u +%Y%m%dT%H%M%SZ); if [ -f "$target" ]; then cp "$target" "$target.pre-restore-$stamp"; fi; rm -f "$target-wal" "$target-shm"; cp /tmp/oikos-restore.db "$target"; chown node:node "$target" 2>/dev/null || true'
+docker compose up -d "$SERVICE"
+
${t('settings.backupCliBackupHint')}
+
docker compose exec oikos node -e "import('./server/db.js').then(async db => { await db.backupToFile('/data/oikos-backup.db'); process.exit(0); })"
+docker cp oikos:/data/oikos-backup.db ./oikos-backup.db
+
+
+
+ ` : ''}
+
+ `;
+
+ // Meal-Type-Checkboxen initialisieren
+ const toggles = container.querySelector('#meal-type-toggles');
+ if (toggles) {
+ toggles.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
+ cb.checked = prefs.visible_meal_types.includes(cb.value);
+ });
+ }
+
+ bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
+ if (window.lucide) window.lucide.createIcons();
+}
+
+// --------------------------------------------------------
+// Event-Binding
+// --------------------------------------------------------
+
+function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
+ bindTabEvents(container);
+ bindSettingsDateInputs(container);
+ bindCategoryEvents(container);
+ bindIcsEvents(container, user, icsSubscriptions);
+ bindApiTokenEvents(container, apiTokens);
+ if (typeof bindBackupEvents === 'function') bindBackupEvents(container);
+ // Theme-Toggle
+ const themeToggle = container.querySelector('#theme-toggle');
+ if (themeToggle) {
+ themeToggle.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-theme-value]');
+ if (!btn) return;
+ const value = btn.dataset.themeValue;
+ applyTheme(value);
+ themeToggle.querySelectorAll('.theme-toggle__btn').forEach(b => b.classList.remove('theme-toggle__btn--active'));
+ btn.classList.add('theme-toggle__btn--active');
+ });
+ }
+
+ // Modul-Toggles (admin-only)
+ const moduleToggles = container.querySelector('#module-toggles');
+ if (moduleToggles) {
+ moduleToggles.addEventListener('change', async () => {
+ const disabled = [...moduleToggles.querySelectorAll('input:not(:checked)')].map((cb) => cb.value);
+ try {
+ const res = await api.put('/preferences', { disabled_modules: disabled });
+ const saved = res?.data?.disabled_modules ?? disabled;
+ window.oikos?.setDisabledModules?.(saved);
+ window.oikos?.showToast(t('settings.modulesSaved'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ // Meal-Type-Toggles
+ const mealToggles = container.querySelector('#meal-type-toggles');
+ if (mealToggles) {
+ mealToggles.addEventListener('change', async () => {
+ const checked = [...mealToggles.querySelectorAll('input:checked')].map((cb) => cb.value);
+ if (checked.length === 0) {
+ window.oikos?.showToast(t('settings.mealTypesMinOne'), 'error');
+ // Revert: re-check all
+ mealToggles.querySelectorAll('input').forEach((cb) => { cb.checked = true; });
+ return;
+ }
+ try {
+ await api.put('/preferences', { visible_meal_types: checked });
+ window.oikos?.showToast(t('settings.mealTypesSaved'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ // Währungs-Auswahl
+ const currencySelect = container.querySelector('#currency-select');
+ if (currencySelect) {
+ currencySelect.addEventListener('change', async () => {
+ try {
+ await api.put('/preferences', { currency: currencySelect.value });
+ window.oikos?.showToast(t('settings.currencySaved'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ const dateFormatSelect = container.querySelector('#date-format-select');
+ if (dateFormatSelect) {
+ dateFormatSelect.addEventListener('change', async () => {
+ try {
+ await api.put('/preferences', { date_format: dateFormatSelect.value });
+ try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } }));
+ window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ const timeFormatSelect = container.querySelector('#time-format-select');
+ if (timeFormatSelect) {
+ timeFormatSelect.addEventListener('change', async () => {
+ try {
+ await api.put('/preferences', { time_format: timeFormatSelect.value });
+ try { localStorage.setItem('oikos-time-format', timeFormatSelect.value); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('time-format-changed', { detail: { timeFormat: timeFormatSelect.value } }));
+ window.oikos?.showToast(t('settings.timeFormatSavedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ const appNameForm = container.querySelector('#app-name-form');
+ if (appNameForm) {
+ appNameForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#app-name-error');
+ const input = container.querySelector('#app-name-input');
+ errorEl.hidden = true;
+ const value = input.value.trim();
+ try {
+ await api.put('/preferences', { app_name: value });
+ try {
+ if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value);
+ else localStorage.removeItem(APP_NAME_STORAGE_KEY);
+ } catch (_) {}
+ input.value = value || DEFAULT_APP_NAME;
+ window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } }));
+ window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+
+ container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => {
+ const errorEl = container.querySelector('#app-name-error');
+ const input = container.querySelector('#app-name-input');
+ errorEl.hidden = true;
+ input.value = DEFAULT_APP_NAME;
+ try {
+ await api.put('/preferences', { app_name: '' });
+ try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } }));
+ window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+ }
+
+ const profileState = { avatarData: user?.avatar_data ?? null };
+ const profileAvatarFile = container.querySelector('#profile-avatar-file');
+ bindAvatarPicker(container, 'profile');
+ if (profileAvatarFile) {
+ profileAvatarFile.addEventListener('change', async () => {
+ const errorEl = container.querySelector('#profile-error');
+ errorEl.hidden = true;
+ try {
+ const avatarData = await readImageAsDataUrl(profileAvatarFile.files?.[0]);
+ if (avatarData !== undefined) {
+ profileState.avatarData = avatarData;
+ setAvatarPreview(container, '#profile-avatar-preview', {
+ display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
+ avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
+ avatar_data: avatarData,
+ });
+ }
+ } catch (err) {
+ profileAvatarFile.value = '';
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+ }
+
+ container.querySelector('#profile-avatar-remove')?.addEventListener('click', () => {
+ profileState.avatarData = null;
+ if (profileAvatarFile) profileAvatarFile.value = '';
+ setAvatarPreview(container, '#profile-avatar-preview', {
+ display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
+ avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
+ avatar_data: null,
+ });
+ });
+
+ const profileForm = container.querySelector('#profile-form');
+ if (profileForm) {
+ profileForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#profile-error');
+ const btn = profileForm.querySelector('[type=submit]');
+ const birthDateRaw = container.querySelector('#profile-birth-date')?.value || '';
+ errorEl.hidden = true;
+ if (!isDateInputValid(birthDateRaw)) {
+ showError(errorEl, t('settings.memberBirthDateInvalid'));
+ return;
+ }
+ btn.disabled = true;
+ try {
+ const res = await auth.updateProfile({
+ display_name: container.querySelector('#profile-display-name').value.trim(),
+ avatar_color: container.querySelector('#profile-avatar-color').value,
+ avatar_data: profileState.avatarData,
+ phone: container.querySelector('#profile-phone')?.value.trim() || null,
+ email: container.querySelector('#profile-email')?.value.trim() || null,
+ birth_date: parseDateInput(birthDateRaw) || null,
+ });
+ Object.assign(user, res.user);
+ window.oikos?.showToast(t('settings.profileSavedToast'), 'success');
+ render(container, { user });
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+
+ // Passwort ändern
+ const passwordForm = container.querySelector('#password-form');
+ if (passwordForm) {
+ passwordForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const currentPw = container.querySelector('#current-password').value;
+ const newPw = container.querySelector('#new-password').value;
+ const confirmPw = container.querySelector('#confirm-password').value;
+ const errorEl = container.querySelector('#password-error');
+
+ errorEl.hidden = true;
+
+ if (newPw !== confirmPw) {
+ showError(errorEl, t('settings.passwordMismatch'));
+ return;
+ }
+
+ const btn = passwordForm.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
+ passwordForm.reset();
+ window.oikos?.showToast(t('settings.passwordSavedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+
+ // Google Sync
+ const googleSyncBtn = container.querySelector('#google-sync-btn');
+ if (googleSyncBtn) {
+ googleSyncBtn.addEventListener('click', async () => {
+ googleSyncBtn.disabled = true;
+ googleSyncBtn.textContent = t('settings.synchronizing');
+ try {
+ await api.post('/calendar/google/sync', {});
+ window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ } finally {
+ googleSyncBtn.disabled = false;
+ googleSyncBtn.textContent = t('settings.syncNow');
+ }
+ });
+ }
+
+ // Google Disconnect (Admin)
+ const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
+ if (googleDisconnectBtn) {
+ googleDisconnectBtn.addEventListener('click', async () => {
+ if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return;
+ try {
+ await api.delete('/calendar/google/disconnect');
+ window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ }
+
+ // Apple Sync
+ const appleSyncBtn = container.querySelector('#apple-sync-btn');
+ if (appleSyncBtn) {
+ appleSyncBtn.addEventListener('click', async () => {
+ appleSyncBtn.disabled = true;
+ appleSyncBtn.textContent = t('settings.synchronizing');
+ try {
+ await api.post('/calendar/apple/sync', {});
+ window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ } finally {
+ appleSyncBtn.disabled = false;
+ appleSyncBtn.textContent = t('settings.syncNow');
+ }
+ });
+ }
+
+ // Apple Disconnect (Admin)
+ const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
+ if (appleDisconnectBtn) {
+ appleDisconnectBtn.addEventListener('click', async () => {
+ if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return;
+ try {
+ await api.delete('/calendar/apple/disconnect');
+ window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ }
+
+ // Apple Connect-Formular (Admin)
+ const appleConnectForm = container.querySelector('#apple-connect-form');
+ if (appleConnectForm) {
+ appleConnectForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#apple-connect-error');
+ errorEl.hidden = true;
+
+ const url = container.querySelector('#apple-caldav-url').value.trim();
+ const username = container.querySelector('#apple-username').value.trim();
+ const password = container.querySelector('#apple-password').value;
+ const btn = container.querySelector('#apple-connect-btn');
+
+ btn.disabled = true;
+ btn.textContent = t('settings.appleConnecting');
+ try {
+ await api.post('/calendar/apple/connect', { url, username, password });
+ window.oikos?.showToast(t('settings.appleConnectedToast'), 'success');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = t('settings.appleConnectBtn');
+ }
+ });
+ }
+
+ // CalDAV-Konten laden
+ async function loadCalDAVAccounts(container) {
+ const listEl = container.querySelector('#caldav-accounts-list');
+ const emptyEl = container.querySelector('#caldav-empty-state');
+ if (!listEl || !emptyEl) return;
+
+ try {
+ const accountsRes = await api.get('/calendar/caldav/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 calendarsRes = await api.get(`/calendar/caldav/accounts/${account.id}/calendars`);
+ const calendars = calendarsRes.data || [];
+
+ const accountCard = document.createElement('div');
+ accountCard.className = 'caldav-account-item';
+ accountCard.insertAdjacentHTML('beforeend', `
+
+
+
+ ${t('settings.caldavCalendarsToggle')} (${calendars.length})
+
+
+ ${calendars.map((cal) => `
+
+ `).join('')}
+
+
+
+
+
+ ${user?.role === 'admin' ? `` : ''}
+
+ `);
+ listEl.appendChild(accountCard);
+ }
+
+ if (window.lucide) lucide.createIcons({ el: listEl });
+
+ // Bind calendar checkbox events
+ listEl.querySelectorAll('.caldav-calendar-checkbox').forEach((checkbox) => {
+ checkbox.addEventListener('change', async () => {
+ const accountId = parseInt(checkbox.dataset.accountId, 10);
+ const calendarUrl = checkbox.dataset.calendarUrl;
+ const enabled = checkbox.checked;
+
+ try {
+ await api.patch(`/calendar/caldav/accounts/${accountId}/calendars`, {
+ calendarUrl,
+ enabled,
+ });
+ window.oikos?.showToast(
+ enabled ? t('settings.calendarEnabled') : t('settings.calendarDisabled'),
+ 'success'
+ );
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ checkbox.checked = !enabled; // Revert on error
+ }
+ });
+ });
+
+ // Bind sync buttons
+ listEl.querySelectorAll('[data-caldav-sync]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ const originalText = btn.textContent;
+ btn.textContent = t('settings.synchronizing');
+ try {
+ await api.post('/calendar/caldav/sync');
+ window.oikos?.showToast(t('settings.caldavSyncSuccess'), 'success');
+ await loadCalDAVAccounts(container);
+ } catch (err) {
+ window.oikos?.showToast(err.message || t('settings.caldavSyncFailed'), 'danger');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
+ });
+ });
+
+ // Bind refresh buttons
+ listEl.querySelectorAll('[data-caldav-refresh]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const accountId = parseInt(btn.dataset.caldavRefresh, 10);
+ btn.disabled = true;
+ const originalText = btn.textContent;
+ btn.textContent = t('settings.loading');
+ try {
+ await api.get(`/calendar/caldav/accounts/${accountId}/calendars?refresh=true`);
+ await loadCalDAVAccounts(container);
+ window.oikos?.showToast(t('settings.calendarsRefreshed'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
+ });
+ });
+
+ // Bind delete buttons
+ listEl.querySelectorAll('[data-caldav-delete]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const accountId = parseInt(btn.dataset.caldavDelete, 10);
+ if (!await confirmModal(t('settings.deleteAccountConfirm'), { danger: true })) return;
+ try {
+ await api.delete(`/calendar/caldav/accounts/${accountId}`);
+ window.oikos?.showToast(t('settings.caldavAccountDeleted'), 'success');
+ await loadCalDAVAccounts(container);
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ });
+
+ } catch (err) {
+ console.error('Failed to load CalDAV accounts:', err);
+ window.oikos?.showToast(t('settings.caldavConnectionFailed'), 'danger');
+ }
+ }
+
+ // Load CalDAV accounts on page load
+ if (user?.role === 'admin') {
+ loadCalDAVAccounts(container);
+ }
+
+ // CalDAV add account button
+ const caldavAddBtn = container.querySelector('#caldav-add-account-btn');
+ if (caldavAddBtn) {
+ caldavAddBtn.addEventListener('click', () => {
+ openModal({
+ title: t('settings.caldavAddAccount'),
+ size: 'sm',
+ content: `
+
+ `,
+ onSave: async (panel) => {
+ const form = panel.querySelector('#caldav-add-form');
+ const errorEl = panel.querySelector('#caldav-add-error');
+ errorEl.hidden = true;
+
+ const name = panel.querySelector('#caldav-name').value.trim();
+ const caldavUrl = panel.querySelector('#caldav-url').value.trim();
+ const username = panel.querySelector('#caldav-username').value.trim();
+ const password = panel.querySelector('#caldav-password').value;
+
+ if (!name || !caldavUrl || !username || !password) {
+ showError(errorEl, t('common.requiredFields'));
+ return;
+ }
+
+ try {
+ await api.post('/calendar/caldav/accounts', {
+ name,
+ caldavUrl,
+ username,
+ password,
+ });
+ closeModal({ force: true });
+ window.oikos?.showToast(t('settings.caldavAccountAdded'), 'success');
+ await loadCalDAVAccounts(container);
+ } catch (err) {
+ showError(errorEl, err.message);
+ }
+ },
+ });
+ });
+ }
+
+ // Mitglied hinzufügen (Admin)
+ const addMemberBtn = container.querySelector('#add-member-btn');
+ if (addMemberBtn) {
+ addMemberBtn.addEventListener('click', () => {
+ container.querySelector('#add-member-form-card').classList.remove('settings-card--hidden');
+ addMemberBtn.hidden = true;
+ });
+ }
+
+ const cancelAddMember = container.querySelector('#cancel-add-member');
+ if (cancelAddMember) {
+ cancelAddMember.addEventListener('click', () => {
+ container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
+ container.querySelector('#add-member-btn').hidden = false;
+ container.querySelector('#add-member-form').reset();
+ container.querySelector('#member-error').hidden = true;
+ });
+ }
+
+ const addMemberForm = container.querySelector('#add-member-form');
+ if (addMemberForm) {
+ addMemberForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#member-error');
+ errorEl.hidden = true;
+ const birthDateRaw = container.querySelector('#new-member-birth-date')?.value || '';
+ if (!isDateInputValid(birthDateRaw)) {
+ showError(errorEl, t('settings.memberBirthDateInvalid'));
+ return;
+ }
+
+ const data = {
+ username: container.querySelector('#new-username').value.trim(),
+ display_name: container.querySelector('#new-display-name').value.trim(),
+ password: container.querySelector('#new-member-password').value,
+ avatar_color: container.querySelector('#new-avatar-color').value,
+ family_role: container.querySelector('#new-family-role').value,
+ system_admin: container.querySelector('#new-system-admin')?.checked === true,
+ phone: container.querySelector('#new-member-phone')?.value.trim() || null,
+ email: container.querySelector('#new-member-email')?.value.trim() || null,
+ birth_date: parseDateInput(birthDateRaw) || null,
+ };
+
+ const btn = addMemberForm.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ const res = await auth.createUser(data);
+ const list = container.querySelector('#members-list');
+ users.push(res.user);
+ list.insertAdjacentHTML('beforeend', memberHtml(res.user));
+ addMemberForm.reset();
+ container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
+ container.querySelector('#add-member-btn').hidden = false;
+ window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success');
+ bindDeleteButtons(container, user);
+ bindEditButtons(container, user, users);
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+
+ bindDeleteButtons(container, user);
+ bindEditButtons(container, user, users);
+
+ // Abmelden
+ const logoutBtn = container.querySelector('#logout-btn');
+ if (logoutBtn) {
+ logoutBtn.addEventListener('click', async () => {
+ try {
+ await auth.logout();
+ } finally {
+ window.location.href = '/login';
+ }
+ });
+ }
+}
+
+// --------------------------------------------------------
+// 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) {
+ container.querySelectorAll('[data-delete-user]').forEach((btn) => {
+ btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
+ });
+ container.querySelectorAll('[data-delete-user]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.dataset.deleteUser, 10);
+ const name = btn.dataset.name;
+ if (!await confirmModal(t('settings.deleteMemberConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
+ try {
+ await auth.deleteUser(id);
+ btn.closest('.settings-member').remove();
+ window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ });
+}
+
+function bindEditButtons(container, currentUser, users) {
+ container.querySelectorAll('[data-edit-user]').forEach((btn) => {
+ btn.replaceWith(btn.cloneNode(true));
+ });
+ container.querySelectorAll('[data-edit-user]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.editUser, 10);
+ const member = users.find((u) => u.id === id);
+ if (member) openEditMemberModal(member, currentUser, users, container);
+ });
+ });
+}
+
+function openEditMemberModal(member, currentUser, users, container) {
+ const state = { avatarData: member.avatar_data ?? null };
+ openModal({
+ title: t('settings.editMemberTitle'),
+ size: 'md',
+ content: `
+
+ `,
+ onSave(panel) {
+ const fileInput = panel.querySelector('#edit-member-avatar-file');
+ const errorEl = panel.querySelector('#edit-member-error');
+ bindSettingsDateInputs(panel);
+ bindAvatarPicker(panel, 'edit-member');
+ fileInput?.addEventListener('change', async () => {
+ errorEl.hidden = true;
+ try {
+ const avatarData = await readImageAsDataUrl(fileInput.files?.[0]);
+ if (avatarData !== undefined) {
+ state.avatarData = avatarData;
+ setAvatarPreview(panel, '#edit-member-avatar-preview', {
+ display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
+ avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
+ avatar_data: avatarData,
+ });
+ }
+ } catch (err) {
+ fileInput.value = '';
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+
+ panel.querySelector('#edit-member-avatar-remove')?.addEventListener('click', () => {
+ state.avatarData = null;
+ if (fileInput) fileInput.value = '';
+ setAvatarPreview(panel, '#edit-member-avatar-preview', {
+ display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
+ avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
+ avatar_data: null,
+ });
+ });
+
+ panel.querySelector('#edit-member-cancel')?.addEventListener('click', closeModal);
+ panel.querySelector('#edit-member-form')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = panel.querySelector('[type=submit]');
+ errorEl.hidden = true;
+ const birthDateRaw = panel.querySelector('#edit-member-birth-date')?.value || '';
+ if (!isDateInputValid(birthDateRaw)) {
+ showError(errorEl, t('settings.memberBirthDateInvalid'));
+ submitBtn.disabled = false;
+ return;
+ }
+ submitBtn.disabled = true;
+ try {
+ const res = await auth.updateUser(member.id, {
+ username: panel.querySelector('#edit-member-username').value.trim(),
+ display_name: panel.querySelector('#edit-member-display-name').value.trim(),
+ avatar_color: panel.querySelector('#edit-member-avatar-color').value,
+ avatar_data: state.avatarData,
+ family_role: panel.querySelector('#edit-member-family-role').value,
+ system_admin: panel.querySelector('#edit-member-system-admin').checked,
+ phone: panel.querySelector('#edit-member-phone')?.value.trim() || null,
+ email: panel.querySelector('#edit-member-email')?.value.trim() || null,
+ birth_date: parseDateInput(birthDateRaw) || null,
+ });
+ const idx = users.findIndex((u) => u.id === member.id);
+ if (idx !== -1) users[idx] = res.user;
+ if (currentUser.id === member.id) Object.assign(currentUser, res.user);
+ closeModal({ force: true });
+ window.oikos?.showToast(t('settings.memberUpdatedToast', { name: res.user.display_name }), 'success');
+ render(container, { user: currentUser });
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ } finally {
+ submitBtn.disabled = false;
+ }
+ });
+ },
+ });
+}
+
+function apiTokenHtml(token) {
+ const status = token.revoked_at
+ ? t('settings.apiTokenRevoked')
+ : token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
+ ? t('settings.apiTokenExpired')
+ : t('settings.apiTokenActive');
+ const meta = [
+ `${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
+ token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
+ token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
+ status,
+ ].join(' · ');
+
+ return `
+
+
+ ${esc(token.name)}
+ ${esc(meta)}
+
+
+
+ `;
+}
+
+function renderApiTokenList(container, tokens) {
+ const list = container.querySelector('#api-token-list');
+ if (!list) return;
+ list.replaceChildren();
+ tokens.forEach((token) => {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = apiTokenHtml(token);
+ list.appendChild(tmp.content.firstElementChild);
+ });
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function datetimeLocalToIso(value) {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+}
+
+function bindApiTokenEvents(container, initialTokens) {
+ const form = container.querySelector('#api-token-form');
+ const list = container.querySelector('#api-token-list');
+ if (!form || !list) return;
+
+ let tokens = [...initialTokens];
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#api-token-error');
+ const output = container.querySelector('#api-token-created');
+ const outputValue = container.querySelector('#api-token-created-value');
+ errorEl.hidden = true;
+ output.hidden = true;
+
+ const name = container.querySelector('#api-token-name').value.trim();
+ const expiresValue = container.querySelector('#api-token-expires').value;
+ const expires_at = datetimeLocalToIso(expiresValue);
+ if (expiresValue && !expires_at) {
+ showError(errorEl, t('settings.apiTokenInvalidExpiration'));
+ return;
+ }
+
+ const btn = form.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ const res = await api.post('/auth/api-tokens', { name, expires_at });
+ tokens.unshift(res.data);
+ renderApiTokenList(container, tokens);
+ form.reset();
+ outputValue.value = res.token;
+ output.hidden = false;
+ outputValue.focus();
+ outputValue.select();
+ window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
+ list.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-revoke-api-token]');
+ if (!btn) return;
+ const id = Number(btn.dataset.revokeApiToken);
+ const name = btn.dataset.name;
+ if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return;
+ try {
+ await api.delete(`/auth/api-tokens/${id}`);
+ tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token);
+ renderApiTokenList(container, tokens);
+ window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+}
+
+async function loadBackupSchedulerStatus(container) {
+ const infoContainer = container.querySelector('#backup-scheduler-info');
+ if (!infoContainer) return;
+
+ try {
+ const res = await api.get('/backup/status');
+ const scheduler = res.data?.scheduler;
+ if (!scheduler) return;
+
+ const { enabled, schedule, keepCount, lastBackup } = scheduler;
+
+ let lastBackupText = t('settings.backupSchedulerNever');
+ if (lastBackup?.timestamp) {
+ const date = formatDate(lastBackup.timestamp) + ' ' + formatTime(lastBackup.timestamp);
+ lastBackupText = lastBackup.success
+ ? t('settings.backupSchedulerLastSuccess', { date })
+ : t('settings.backupSchedulerLastFail', { date });
+ }
+
+ const html = `
+
+ ${t('settings.backupSchedulerStatus')}
+
+ ${enabled ? t('settings.backupSchedulerEnabled') : t('settings.backupSchedulerDisabled')}
+
+
+ ${enabled ? `
+
+ ${t('settings.backupSchedulerSchedule')}
+ ${esc(schedule)}
+
+
+ ${t('settings.backupSchedulerKeep')}
+ ${t('settings.backupSchedulerKeepCount', { count: keepCount })}
+
+
+ ${t('settings.backupSchedulerLastBackup')}
+ ${esc(lastBackupText)}
+
+
+
+
+ ` : ''}
+ `;
+
+ infoContainer.replaceChildren();
+ infoContainer.insertAdjacentHTML('beforeend', html);
+
+ if (window.lucide) window.lucide.createIcons();
+
+ // Event-Handler für manuellen Trigger
+ const triggerBtn = infoContainer.querySelector('#backup-trigger-btn');
+ if (triggerBtn) {
+ triggerBtn.addEventListener('click', async () => {
+ triggerBtn.disabled = true;
+ triggerBtn.textContent = t('settings.backupSchedulerTriggering');
+ try {
+ await api.post('/backup/trigger');
+ window.oikos?.showToast(t('settings.backupSchedulerTriggeredToast'), 'success');
+ // Status neu laden
+ loadBackupSchedulerStatus(container);
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ triggerBtn.disabled = false;
+ triggerBtn.textContent = t('settings.backupSchedulerTrigger');
+ }
+ });
+ }
+ } catch (err) {
+ console.error('Failed to load backup scheduler status:', err);
+ }
+}
+
+function bindBackupEvents(container) {
+ // Scheduler-Status laden und anzeigen
+ loadBackupSchedulerStatus(container);
+
+ const form = container.querySelector('#backup-restore-form');
+ const fileInput = container.querySelector('#backup-restore-file');
+ const selectedFile = container.querySelector('#backup-selected-file');
+ const restoreBtn = container.querySelector('#backup-restore-btn');
+ const errorEl = container.querySelector('#backup-restore-error');
+ const dropzone = container.querySelector('#backup-dropzone');
+
+ if (!form || !fileInput || !selectedFile || !restoreBtn || !errorEl) return;
+
+ function setFile(file) {
+ if (!file) {
+ selectedFile.hidden = true;
+ selectedFile.textContent = '';
+ restoreBtn.disabled = true;
+ return;
+ }
+ selectedFile.textContent = `${file.name} · ${Math.round(file.size / 1024)} KB`;
+ selectedFile.hidden = false;
+ restoreBtn.disabled = false;
+ }
+
+ fileInput.addEventListener('change', () => {
+ errorEl.hidden = true;
+ setFile(fileInput.files?.[0]);
+ });
+
+ dropzone?.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropzone.classList.add('settings-backup-dropzone--active');
+ });
+
+ dropzone?.addEventListener('dragleave', () => {
+ dropzone.classList.remove('settings-backup-dropzone--active');
+ });
+
+ dropzone?.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropzone.classList.remove('settings-backup-dropzone--active');
+ const file = e.dataTransfer?.files?.[0];
+ if (!file) return;
+ const transfer = new DataTransfer();
+ transfer.items.add(file);
+ fileInput.files = transfer.files;
+ errorEl.hidden = true;
+ setFile(file);
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const file = fileInput.files?.[0];
+ if (!file) return;
+ if (!await confirmModal(t('settings.backupRestoreConfirm'), { danger: true, confirmLabel: t('settings.backupRestoreButton') })) return;
+
+ errorEl.hidden = true;
+ restoreBtn.disabled = true;
+ restoreBtn.textContent = t('settings.backupRestoring');
+ try {
+ await api.rawPost('/backup/restore', file);
+ window.oikos?.showToast(t('settings.backupRestoredToast'), 'success');
+ window.location.reload();
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ restoreBtn.disabled = false;
+ restoreBtn.textContent = t('settings.backupRestoreButton');
+ }
+ });
+}
+
+
+// --------------------------------------------------------
+// Kategorie-Verwaltung
+// --------------------------------------------------------
+
+function categoryRowHtml(cat, isFirst, isLast) {
+ return `
+
+
+ ${esc(catLabel(cat.name))}
+
+
+
+
+
+ `;
+}
+
+function renderCatList(container, cats) {
+ const list = container.querySelector('#cat-list');
+ if (!list) return;
+ // DOM-API statt innerHTML (Security-Constraint des Projekts)
+ list.replaceChildren();
+ cats.forEach((c, i) => {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = categoryRowHtml(c, i === 0, i === cats.length - 1);
+ list.appendChild(tmp.content.firstElementChild);
+ });
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function bindCategoryEvents(container) {
+ let cats = [];
+
+ api.get('/shopping/categories').then((res) => {
+ cats = res.data ?? [];
+ renderCatList(container, cats);
+ }).catch(() => {});
+
+ const addForm = container.querySelector('#cat-add-form');
+ if (addForm) {
+ addForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const input = container.querySelector('#cat-add-input');
+ const name = input.value.trim();
+ if (!name) return;
+ try {
+ const res = await api.post('/shopping/categories', { name });
+ cats.push(res.data);
+ renderCatList(container, cats);
+ input.value = '';
+ input.focus();
+ window.oikos?.showToast(t('settings.shoppingCategoryAdded'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ }
+
+ const catList = container.querySelector('#cat-list');
+ if (!catList) return;
+
+ catList.addEventListener('click', async (e) => {
+ const target = e.target.closest('[data-action]');
+ if (!target) return;
+ const action = target.dataset.action;
+ const rowEl = target.closest('[data-cat-id]');
+ const id = rowEl ? Number(rowEl.dataset.catId) : Number(target.dataset.id);
+
+ if (action === 'rename-cat') {
+ const cat = cats.find((c) => c.id === id);
+ if (!cat) return;
+ const { promptModal } = await import('/components/modal.js');
+ const newName = await promptModal(t('settings.shoppingCategoryRenamePrompt'), catLabel(cat.name));
+ if (!newName || newName === cat.name) return;
+ try {
+ const res = await api.put(`/shopping/categories/${id}`, { name: newName });
+ const idx = cats.findIndex((c) => c.id === id);
+ if (idx >= 0) cats[idx] = res.data;
+ renderCatList(container, cats);
+ window.oikos?.showToast(t('settings.shoppingCategoryRenamed'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ }
+
+ if (action === 'move-cat-up') {
+ const idx = cats.findIndex((c) => c.id === id);
+ if (idx <= 0) return;
+ [cats[idx - 1], cats[idx]] = [cats[idx], cats[idx - 1]];
+ renderCatList(container, cats);
+ try {
+ await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ }
+
+ if (action === 'move-cat-down') {
+ const idx = cats.findIndex((c) => c.id === id);
+ if (idx < 0 || idx >= cats.length - 1) return;
+ [cats[idx], cats[idx + 1]] = [cats[idx + 1], cats[idx]];
+ renderCatList(container, cats);
+ try {
+ await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ }
+
+ if (action === 'delete-cat') {
+ const cat = cats.find((c) => c.id === id);
+ if (!cat) return;
+ const { confirmModal: confirmDel } = await import('/components/modal.js');
+ if (!await confirmDel(
+ t('settings.shoppingCategoryDeleteConfirm', { name: catLabel(cat.name) }),
+ { danger: true, confirmLabel: t('common.delete') }
+ )) return;
+ try {
+ await api.delete(`/shopping/categories/${id}`);
+ cats = cats.filter((c) => c.id !== id);
+ renderCatList(container, cats);
+ window.oikos?.showToast(t('settings.shoppingCategoryDeleted'), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ }
+ });
+}
+
+function memberHtml(u) {
+ const familyRole = familyRoleLabel(u.family_role);
+ const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
+ const profileMeta = [
+ u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
+ u.email || '',
+ u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
+ ].filter(Boolean).map(esc).join(' · ');
+ return `
+
+ ${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
+
+ ${esc(u.display_name)}
+ @${esc(u.username)} · ${esc(familyRole)}${systemRole}
+ ${profileMeta ? `${profileMeta}` : ''}
+
+
+
+
+ `;
+}
+
+// --------------------------------------------------------
+// ICS-Abonnements
+// --------------------------------------------------------
+
+function renderIcsList(container, subs, user) {
+ const listEl = container.querySelector('#ics-list-container');
+ if (!listEl) return;
+ listEl.replaceChildren();
+
+ if (subs.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'form-hint';
+ empty.style.padding = 'var(--space-3) 0';
+ empty.textContent = t('settings.ics.empty');
+ listEl.appendChild(empty);
+ return;
+ }
+
+ const ul = document.createElement('ul');
+ ul.className = 'settings-members';
+ subs.forEach((sub) => {
+ const li = document.createElement('li');
+ li.className = 'settings-member';
+ li.dataset.subId = sub.id;
+
+ const dot = document.createElement('span');
+ dot.className = 'settings-avatar settings-avatar--sm';
+ dot.style.background = sub.color;
+ dot.style.flexShrink = '0';
+ li.appendChild(dot);
+
+ const info = document.createElement('div');
+ info.className = 'settings-member__info';
+
+ const nameLine = document.createElement('span');
+ nameLine.className = 'settings-member__name';
+ nameLine.textContent = sub.name;
+
+ const badge = document.createElement('span');
+ badge.className = `badge ${sub.shared ? 'badge--success' : 'badge--neutral'}`;
+ badge.style.marginLeft = 'var(--space-2)';
+ badge.textContent = sub.shared ? t('settings.ics.badges.shared') : t('settings.ics.badges.private');
+ nameLine.appendChild(badge);
+ info.appendChild(nameLine);
+
+ const meta = document.createElement('span');
+ meta.className = 'settings-member__meta';
+ if (sub.last_sync) {
+ const d = new Date(sub.last_sync);
+ meta.textContent = `${t('settings.ics.status.lastSync')} ${formatDate(d)} ${formatTime(d)}`;
+ } else {
+ meta.textContent = t('settings.ics.status.never');
+ }
+ info.appendChild(meta);
+ li.appendChild(info);
+
+ const isOwner = sub.created_by === user.id || user.role === 'admin';
+ if (isOwner) {
+ const actions = document.createElement('div');
+ actions.className = 'cat-row__actions';
+
+ const syncBtn = document.createElement('button');
+ syncBtn.className = 'btn btn--icon btn--ghost';
+ syncBtn.title = t('settings.ics.actions.sync');
+ syncBtn.setAttribute('aria-label', t('settings.ics.actions.sync'));
+ syncBtn.dataset.action = 'ics-sync';
+ syncBtn.dataset.id = sub.id;
+ const syncIcon = document.createElement('i');
+ syncIcon.setAttribute('data-lucide', 'refresh-cw');
+ syncIcon.style.cssText = 'width:16px;height:16px';
+ syncIcon.setAttribute('aria-hidden', 'true');
+ syncBtn.appendChild(syncIcon);
+ actions.appendChild(syncBtn);
+
+ const editBtn = document.createElement('button');
+ editBtn.className = 'btn btn--icon btn--ghost';
+ editBtn.title = t('settings.ics.actions.edit');
+ editBtn.setAttribute('aria-label', t('settings.ics.actions.edit'));
+ editBtn.dataset.action = 'ics-edit';
+ editBtn.dataset.id = sub.id;
+ const editIcon = document.createElement('i');
+ editIcon.setAttribute('data-lucide', 'pencil');
+ editIcon.style.cssText = 'width:14px;height:14px';
+ editIcon.setAttribute('aria-hidden', 'true');
+ editBtn.appendChild(editIcon);
+ actions.appendChild(editBtn);
+
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn btn--icon btn--danger-outline';
+ delBtn.title = t('settings.ics.actions.delete');
+ delBtn.setAttribute('aria-label', t('settings.ics.actions.delete'));
+ delBtn.dataset.action = 'ics-delete';
+ delBtn.dataset.id = sub.id;
+ delBtn.dataset.name = sub.name;
+ const delIcon = document.createElement('i');
+ delIcon.setAttribute('data-lucide', 'trash-2');
+ delIcon.style.cssText = 'width:14px;height:14px';
+ delIcon.setAttribute('aria-hidden', 'true');
+ delBtn.appendChild(delIcon);
+ actions.appendChild(delBtn);
+
+ li.appendChild(actions);
+ }
+
+ ul.appendChild(li);
+ });
+ listEl.appendChild(ul);
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function bindIcsEvents(container, user, initialSubs) {
+ let subs = [...initialSubs];
+ renderIcsList(container, subs, user);
+
+ const addBtn = container.querySelector('#ics-add-btn');
+ const formWrapper = container.querySelector('#ics-add-form-wrapper');
+ const addForm = container.querySelector('#ics-add-form');
+ const cancelBtn = container.querySelector('#ics-cancel-btn');
+ const submitBtn = container.querySelector('#ics-submit-btn');
+ const errorEl = container.querySelector('#ics-add-error');
+ const listEl = container.querySelector('#ics-list-container');
+
+ if (addBtn) {
+ addBtn.addEventListener('click', () => {
+ formWrapper.hidden = false;
+ addBtn.hidden = true;
+ container.querySelector('#ics-url')?.focus();
+ });
+ }
+
+ if (cancelBtn) {
+ cancelBtn.addEventListener('click', () => {
+ formWrapper.hidden = true;
+ addBtn.hidden = false;
+ addForm?.reset();
+ errorEl.hidden = true;
+ });
+ }
+
+ if (addForm) {
+ addForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ errorEl.hidden = true;
+ const url = container.querySelector('#ics-url').value.trim();
+ const name = container.querySelector('#ics-name').value.trim();
+ const color = container.querySelector('#ics-color').value;
+ const shared = container.querySelector('#ics-shared').checked ? 1 : 0;
+
+ submitBtn.disabled = true;
+ try {
+ const res = await api.post('/calendar/subscriptions', { url, name, color, shared });
+ subs.push(res.data);
+ renderIcsList(container, subs, user);
+ addForm.reset();
+ formWrapper.hidden = true;
+ addBtn.hidden = false;
+ if (res.syncError) {
+ window.oikos?.showToast(`${t('settings.ics.status.syncError')}: ${res.syncError}`, 'danger');
+ } else {
+ window.oikos?.showToast(t('settings.ics.addedToast'), 'success');
+ }
+ } catch (err) {
+ errorEl.textContent = err.message ?? t('common.errorGeneric');
+ errorEl.hidden = false;
+ } finally {
+ submitBtn.disabled = false;
+ }
+ });
+ }
+
+ if (listEl) {
+ listEl.addEventListener('click', async (e) => {
+ const target = e.target.closest('[data-action]');
+ if (!target) return;
+ const action = target.dataset.action;
+ const id = parseInt(target.dataset.id, 10);
+
+ if (action === 'ics-sync') {
+ const origIcon = target.querySelector('[data-lucide]');
+ const origTitle = target.title;
+ target.disabled = true;
+ target.title = t('settings.ics.status.syncing');
+ if (origIcon) origIcon.setAttribute('data-lucide', 'loader');
+ if (window.lucide) window.lucide.createIcons();
+ try {
+ const res = await api.post(`/calendar/subscriptions/${id}/sync`, {});
+ const idx = subs.findIndex((s) => s.id === id);
+ if (idx >= 0) subs[idx] = res.data;
+ renderIcsList(container, subs, user);
+ window.oikos?.showToast(t('settings.ics.syncedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ target.disabled = false;
+ target.title = origTitle;
+ if (origIcon) origIcon.setAttribute('data-lucide', 'refresh-cw');
+ if (window.lucide) window.lucide.createIcons();
+ }
+ }
+
+ if (action === 'ics-edit') {
+ const sub = subs.find((s) => s.id === id);
+ if (!sub) return;
+ openModal({
+ title: t('settings.ics.actions.edit'),
+ size: 'sm',
+ content: `
+
+ `,
+ onSave(panel) {
+ panel.querySelector('#ics-edit-cancel')?.addEventListener('click', () => closeModal());
+ panel.querySelector('#ics-edit-form')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = panel.querySelector('[type=submit]');
+ const errEl = panel.querySelector('#ics-edit-error');
+ const name = panel.querySelector('#ics-edit-name').value.trim();
+ const color = panel.querySelector('#ics-edit-color').value;
+ const shared = panel.querySelector('#ics-edit-shared').checked ? 1 : 0;
+ errEl.hidden = true;
+ submitBtn.disabled = true;
+ try {
+ const res = await api.patch(`/calendar/subscriptions/${id}`, { name, color, shared });
+ const idx = subs.findIndex((s) => s.id === id);
+ if (idx >= 0) subs[idx] = res.data;
+ renderIcsList(container, subs, user);
+ window.oikos?.showToast(t('settings.ics.updatedToast'), 'success');
+ closeModal({ force: true });
+ } catch (err) {
+ errEl.textContent = err.message ?? t('common.errorGeneric');
+ errEl.hidden = false;
+ submitBtn.disabled = false;
+ }
+ });
+ },
+ });
+ }
+
+ if (action === 'ics-delete') {
+ const name = target.dataset.name;
+ if (!await confirmModal(t('settings.ics.confirm_delete'), { danger: true, confirmLabel: t('common.delete') })) return;
+ try {
+ await api.delete(`/calendar/subscriptions/${id}`);
+ subs = subs.filter((s) => s.id !== id);
+ renderIcsList(container, subs, user);
+ window.oikos?.showToast(t('settings.ics.deletedToast'), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ }
+ });
+ }
+}
+
+function initials(name) {
+ if (!name) return '?';
+ return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
+}
+
+function formatDateTime(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ return `${formatDate(d)} ${formatTime(d)}`.trim();
+}
+
+function currentTheme() {
+ return localStorage.getItem('oikos-theme') || 'system';
+}
+
+function applyTheme(value) {
+ window.oikos?.applyTheme(value);
+}
+
+function showError(el, msg) {
+ el.textContent = msg;
+ el.hidden = false;
+}
diff --git a/public/styles/settings-nav.css b/public/styles/settings-nav.css
new file mode 100644
index 0000000..1efca8d
--- /dev/null
+++ b/public/styles/settings-nav.css
@@ -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 */
+}
diff --git a/public/styles/settings.css b/public/styles/settings.css
index dbc5a8a..ae2f65d 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -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
-------------------------------------------------------- */
diff --git a/public/utils/settings-nav.js b/public/utils/settings-nav.js
new file mode 100644
index 0000000..a433f59
--- /dev/null
+++ b/public/utils/settings-nav.js
@@ -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);
+}