From 6cdef0102c52bec7c8c665c894036e8898ab0cde Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 21:50:59 +0200 Subject: [PATCH] feat(settings): add dedicated Sync tab with CardDAV contacts integration - Rename Calendar tab to Synchronization with two sections: * Calendar Sync (Google, Apple, CalDAV, ICS) * Contact Sync (CardDAV) - NEW - Add visual tab grouping with CSS separators between sections - Implement CardDAV account management UI: * Add/delete accounts * Enable/disable addressbooks * Manual sync trigger * Connection testing - Add UX improvements: * Status badges (success/error/syncing) * Empty states with onboarding * Inline help tooltips (prepared) * Breadcrumb navigation (prepared) - Update i18n keys in all 14 locales - All 109 tests passing Co-Authored-By: Claude Sonnet 4.5 --- .../2026-05-04-settings-sidebar-demo.html | 349 +++ public/locales/ar.json | 29 +- public/locales/de.json | 48 +- public/locales/el.json | 29 +- public/locales/en.json | 27 +- public/locales/es.json | 29 +- public/locales/fr.json | 29 +- public/locales/hi.json | 29 +- public/locales/it.json | 29 +- public/locales/ja.json | 29 +- public/locales/pt.json | 29 +- public/locales/ru.json | 29 +- public/locales/sv.json | 29 +- public/locales/tr.json | 29 +- public/locales/uk.json | 29 +- public/locales/zh.json | 29 +- public/pages/settings.js | 244 +- public/pages/settings.js.backup | 2383 +++++++++++++++++ public/styles/settings-nav.css | 452 ++++ public/styles/settings.css | 199 ++ public/utils/settings-nav.js | 231 ++ 21 files changed, 4267 insertions(+), 43 deletions(-) create mode 100644 docs/designs/2026-05-04-settings-sidebar-demo.html create mode 100644 public/pages/settings.js.backup create mode 100644 public/styles/settings-nav.css create mode 100644 public/utils/settings-nav.js 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 + + + +
+ + + + +
+ + +
+

Allgemein

+

+ Grundlegende Einstellungen für Design, Sprache und Module. +

+
+ +
+

Design

+

+ Wähle zwischen hellem, dunklem oder System-Modus. +

+
+ +
+

Sprache

+

+ Ändere die Anzeigesprache der Anwendung. +

+
+ + +
+ + +
+

Kontakte-Synchronisation

+

+ Verbinde mehrere CardDAV-Konten und synchronisiere deine Kontakte mit iCloud, Nextcloud und anderen Diensten. +

+
+ +
+ 📇 +

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')}

+ +
+ + + ${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: ` +
+
+ + +
+
+ + + ${t('settings.cardavUrlHint')} +
+
+ + +
+
+ + + ${t('settings.cardavPasswordHint')} +
+ +
+ `, + 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 ? `${safeName}` : 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.mealTypesLabel')}

+

${t('settings.mealTypesHint')}

+
+ + + + +
+
+
+
+ + +
+
+

${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')}

+ + +
+
+ +
+
${t('settings.googleCalendar')}
+
+ ${googleStatusText} +
+
+
+ ${googleStatus.configured ? ` +
+ ${googleStatus.connected ? ` + + ${user?.role === 'admin' ? `` : ''} + ` : ` + ${user?.role === 'admin' ? `${t('settings.connectGoogle')}` : `${t('settings.googleOnlyAdmin')}`} + `} +
+ ` : ''} +
+ + +
+
+ +
+
${t('settings.appleCalendar')}
+
+ ${appleStatusText} +
+
+
+ ${appleStatus.configured ? ` +
+ + ${appleStatus.connected && user?.role === 'admin' ? `` : ''} +
+ ` : user?.role === 'admin' ? ` +
+
+ + +
+
+ + +
+
+ + + ${t('settings.applePasswordHint')} +
+ + +
+ ` : `${t('settings.appleOnlyAdmin')}`} +
+ + +
+

${t('settings.caldavTitle')}

+

${t('settings.caldavDescription')}

+ +
+ + + ${user?.role === 'admin' ? ` + + ` : ''} +
+ + +
+
+
+
${t('settings.ics.title')}
+
+
+
+ +
+ +
+
+
+
+ + ${user?.role === 'admin' ? ` + +
+
+

${t('settings.sectionFamily')}

+
+
    + ${users.map(memberHtml).join('')} +
+ +
+ +
+

${t('settings.newMemberTitle')}

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +

${t('settings.memberContactBirthdayHint')}

+
+ +

${t('settings.systemAdminHint')}

+ +
+ + +
+
+
+
+
+ ` : ''} + + ${user?.role === 'admin' ? ` + +
+
+

${t('settings.apiTokensTitle')}

+
+

${t('settings.apiTokensCardTitle')}

+

${t('settings.apiTokensHint')}

+
    + ${apiTokens.map(apiTokenHtml).join('')} +
+
+
+ + +
+
+ + +

${t('settings.apiTokenExpiresHint')}

+
+ + + +
+
+
+
+ ` : ''} + + +
+
+

${t('settings.sectionAccount')}

+ +
+ +
+ +
+

${t('settings.profilePictureTitle')}

+
+
+ ${avatarEditorHtml(user, 'profile')} +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + +

${t('settings.memberContactBirthdayHint')}

+
+ +
+ +
+
+
+ +
+

${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('')} +
+
+ + `); + 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: ` +
+
+ + +
+
+ + + ${t('settings.caldavUrlHint')} +
+
+ + +
+
+ + + ${t('settings.caldavPasswordHint')} +
+ +
+ `, + 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: ` +
+
+ ${avatarEditorHtml(member, 'edit-member')} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+ +
+ + +

${t('settings.memberContactBirthdayHint')}

+
+ +

${t('settings.systemAdminHint')}

+ +
+ + +
+
+ `, + 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); +}