diff --git a/client/src/App.css b/client/src/App.css index 89e8abe..5132bcf 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1097,6 +1097,7 @@ html.scheme-dark .themed-select-option.is-selected { gap: 12px; } +.profile-field-label, .profile-pin-form .input-group label { display: block; text-align: left; diff --git a/client/src/components/PushNotificationSettings.tsx b/client/src/components/PushNotificationSettings.tsx index dc58804..ddd9762 100644 --- a/client/src/components/PushNotificationSettings.tsx +++ b/client/src/components/PushNotificationSettings.tsx @@ -56,7 +56,7 @@ export default function PushNotificationSettings() { trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) } } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('settings.push_error') + const message = err instanceof Error ? err.message : t('profile.push_error') showAlert(message) void loadPrefs() } finally { @@ -69,10 +69,10 @@ export default function PushNotificationSettings() {
-

{t('settings.push_title')}

+

{t('profile.push_title')}

- {t('settings.push_unsupported')} + {t('profile.push_unsupported')}

) @@ -83,23 +83,23 @@ export default function PushNotificationSettings() {

- {t('settings.push_title')} + {t('profile.push_title')}

- {t('settings.push_desc')} + {t('profile.push_desc')}

{iosNeedsInstall && (

- {t('settings.push_ios_install_hint')} + {t('profile.push_ios_install_hint')}

)} {permission === 'denied' && (

- {t('settings.push_denied_hint')} + {t('profile.push_denied_hint')}

)} @@ -122,12 +122,12 @@ export default function PushNotificationSettings() { disabled={loading || toggling || iosNeedsInstall} style={{ width: '18px', height: '18px', cursor: 'inherit' }} /> - {t('settings.push_enable')} + {t('profile.push_enable')} {enabled && permission === 'granted' && (

- {t('settings.push_active')} + {t('profile.push_active')}

)} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 016756c..5606933 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -1,14 +1,9 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react' +import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react' import { ensureLogbookKey } from '../services/logbookKeys.js' import LogbookBackupPanel from './LogbookBackupPanel.tsx' -import PwaInstallPrompt from './PwaInstallPrompt.tsx' -import PushNotificationSettings from './PushNotificationSettings.tsx' import { useDialog } from './ModalDialog.tsx' -import { notifyAppearanceChanged } from '../services/appearance.js' -import ThemedSelect from './ThemedSelect.tsx' -import { useAppTour } from '../context/AppTourContext.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { apiFetch } from '../services/api.js' @@ -25,7 +20,6 @@ interface Collaborator { createdAt: string } -// Convert ArrayBuffer to Hex String for URL fragment const bufferToHex = (buffer: ArrayBuffer): string => { return Array.from(new Uint8Array(buffer)) .map(b => b.toString(16).padStart(2, '0')) @@ -35,14 +29,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => { export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) { const { t } = useTranslation() const { showConfirm, showAlert } = useDialog() - const { restartTour } = useAppTour() - const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '') - const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto') - const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto') - const [saving, setSaving] = useState(false) - const [success, setSuccess] = useState(false) - // Collaboration States const [collaborators, setCollaborators] = useState([]) const [isOwner, setIsOwner] = useState(true) const [inviteLink, setInviteLink] = useState('') @@ -51,7 +38,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF const [collabError, setCollabError] = useState(null) const [loadingCollabs, setLoadingCollabs] = useState(false) - // Public Share Link States const [shareEnabled, setShareEnabled] = useState(false) const [shareLink, setShareLink] = useState('') const [shareCopied, setShareCopied] = useState(false) @@ -120,9 +106,9 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF } else { throw new Error('Failed to toggle public share link.') } - } catch (err: any) { + } catch (err: unknown) { console.error('Toggle share link failed:', err) - showAlert(err.message || 'Failed to update public share link.') + showAlert(err instanceof Error ? err.message : 'Failed to update public share link.') } finally { setLoadingShareLink(false) } @@ -136,7 +122,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF } } - const loadCollaborators = async () => { setLoadingCollabs(true) setCollabError(null) @@ -173,10 +158,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF if (!localStorage.getItem('active_userid')) return try { - // 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed) const logbookKey = await ensureLogbookKey(logbookId) - // 2. Create invite token on server const res = await apiFetch('/api/collaboration/invite', { method: 'POST', body: JSON.stringify({ logbookId, role: 'WRITE' }) @@ -187,16 +170,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF } const invite = await res.json() - - // 3. Format link containing token (URL params) and key (URL hash anchor) const hexKey = bufferToHex(logbookKey) const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}` - + setInviteLink(link) trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED) - } catch (err: any) { + } catch (err: unknown) { console.error('Failed to generate invite:', err) - showAlert(err.message || 'Failed to generate invite link.') + showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.') } finally { setGeneratingInvite(false) } @@ -225,40 +206,26 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF } else { throw new Error('Failed to revoke collaborator access.') } - } catch (err: any) { + } catch (err: unknown) { console.error('Revocation failed:', err) - showAlert(err.message || 'Failed to revoke access.') + showAlert(err instanceof Error ? err.message : 'Failed to revoke access.') } } } - const persistAppearance = (nextTheme: string, nextColorScheme: string) => { - localStorage.setItem('active_theme', nextTheme) - localStorage.setItem('active_color_scheme', nextColorScheme) - notifyAppearanceChanged() - } - - const handleThemeChange = (nextTheme: string) => { - setTheme(nextTheme) - persistAppearance(nextTheme, colorScheme) - } - - const handleColorSchemeChange = (nextColorScheme: string) => { - setColorScheme(nextColorScheme) - persistAppearance(theme, nextColorScheme) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - setSaving(true) - setSuccess(false) - - localStorage.setItem('owm_api_key', apiKey.trim()) - persistAppearance(theme, colorScheme) - - setSaving(false) - setSuccess(true) - setTimeout(() => setSuccess(false), 3000) + if (!logbookId) { + return ( +
+
+ +
+

{t('settings.title')}

+

{t('settings.subtitle')}

+
+
+

{t('settings.select_logbook_hint')}

+
+ ) } return ( @@ -267,128 +234,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF

{t('settings.title')}

-

- {t('settings.subtitle')} -

+

{t('settings.subtitle')}

-
- - - - {/* Weather Integration card */} -
-

- {t('settings.owm_title')} -

-

- {t('settings.key_help')} -

- -
- - setApiKey(e.target.value)} - disabled={saving} - autoComplete="off" - /> -
-
- - {/* Theme customization card */} -
-

- {t('settings.theme_title')} -

-

- {t('settings.theme_label')} -

- -
- -
-
- -
-

- {t('settings.color_scheme_title')} -

-

- {t('settings.color_scheme_label')} -

- -
- -
-
- -
-
- -

- {t('settings.tour_title')} -

-
-

- {t('settings.tour_desc')} -

- -
- -
- {success && ( -
- - {t('settings.saved')} -
- )} - - -
- - - {/* Public Share Link Card (Only visible to Logbook Owner) */} {logbookId && isOwner && ( -
+

@@ -441,12 +292,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF

)} - {/* Backup & Restore (owner only) */} {logbookId && isOwner && ( )} - {/* Crew Collaboration Card (Only visible to Logbook Owner) */} {logbookId && isOwner && (
@@ -494,7 +343,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)} - {/* Collaborator List */}

{t('logs.collaborators_list')}

diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx index 2d16bfc..c5beb1a 100644 --- a/client/src/components/UserProfilePage.tsx +++ b/client/src/components/UserProfilePage.tsx @@ -28,6 +28,7 @@ import { CircleAlert } from 'lucide-react' import AccountDangerZone from './AccountDangerZone.tsx' +import UserProfilePreferences from './UserProfilePreferences.tsx' import BetaBadge from './BetaBadge.tsx' import { useDialog } from './ModalDialog.tsx' import { @@ -476,6 +477,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro + +
diff --git a/client/src/components/UserProfilePreferences.tsx b/client/src/components/UserProfilePreferences.tsx new file mode 100644 index 0000000..f0b8f1a --- /dev/null +++ b/client/src/components/UserProfilePreferences.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Compass, Palette, Save, Check, Cloud } from 'lucide-react' +import ThemedSelect from './ThemedSelect.tsx' +import PushNotificationSettings from './PushNotificationSettings.tsx' +import PwaInstallPrompt from './PwaInstallPrompt.tsx' +import { notifyAppearanceChanged } from '../services/appearance.js' +import { useAppTour } from '../context/AppTourContext.tsx' +import { + getColorSchemePreference, + getOwmApiKey, + getThemePreference, + setColorSchemePreference, + setOwmApiKey, + setThemePreference +} from '../services/userPreferences.js' + +interface UserProfilePreferencesProps { + userId: string +} + +export default function UserProfilePreferences({ userId }: UserProfilePreferencesProps) { + const { t } = useTranslation() + const { restartTour } = useAppTour() + const [apiKey, setApiKey] = useState(() => getOwmApiKey(userId)) + const [theme, setTheme] = useState(() => getThemePreference(userId)) + const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId)) + const [savingOwm, setSavingOwm] = useState(false) + const [owmSaved, setOwmSaved] = useState(false) + + const persistAppearance = (nextTheme: string, nextColorScheme: string) => { + setThemePreference(userId, nextTheme) + setColorSchemePreference(userId, nextColorScheme) + notifyAppearanceChanged() + } + + const handleThemeChange = (nextTheme: string) => { + setTheme(nextTheme) + persistAppearance(nextTheme, colorScheme) + } + + const handleColorSchemeChange = (nextColorScheme: string) => { + setColorScheme(nextColorScheme) + persistAppearance(theme, nextColorScheme) + } + + const handleSaveOwm = (e: React.FormEvent) => { + e.preventDefault() + setSavingOwm(true) + setOwmSaved(false) + setOwmApiKey(userId, apiKey) + setSavingOwm(false) + setOwmSaved(true) + window.setTimeout(() => setOwmSaved(false), 3000) + } + + return ( + <> +
+
+ +

{t('profile.appearance_title')}

+
+

{t('profile.appearance_desc')}

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

{t('profile.tour_title')}

+
+

{t('profile.tour_desc')}

+
+ +
+
+ +
+
+ +

{t('profile.integrations_title')}

+
+

{t('profile.owm_help')}

+
+
+ + setApiKey(e.target.value)} + disabled={savingOwm} + autoComplete="off" + /> +
+
+ {owmSaved && ( +
+ + {t('profile.prefs_saved')} +
+ )} + +
+
+
+ + + + + ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 54f6336..b6dcf61 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -382,7 +382,35 @@ "stats_subtitle": "Über alle deine Logbücher auf diesem Gerät", "stats_logbooks": "Logbücher", "stats_account_since": "Konto seit", - "stats_shared_logbooks": "Geteilte Logbücher" + "stats_shared_logbooks": "Geteilte Logbücher", + "appearance_title": "App & Darstellung", + "appearance_desc": "Design und Farbschema gelten für die gesamte App auf diesem Gerät.", + "theme_label": "Design-Stil der App", + "theme_auto": "Automatisch (OS-Erkennung)", + "theme_ocean": "Ocean (Glassmorphismus)", + "theme_material": "Material (Android)", + "theme_cupertino": "Cupertino (iOS)", + "color_scheme_label": "Hell- oder Dunkelmodus", + "color_scheme_auto": "Automatisch (System)", + "color_scheme_light": "Hell", + "color_scheme_dark": "Dunkel", + "integrations_title": "Integrationen", + "owm_key": "OpenWeatherMap API-Schlüssel", + "owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", + "prefs_save": "Speichern", + "prefs_saving": "Wird gespeichert…", + "prefs_saved": "Gespeichert", + "tour_title": "App-Tour", + "tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.", + "tour_restart": "Tour erneut starten", + "push_title": "Push-Benachrichtigungen", + "push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.", + "push_enable": "Bei Crew-Änderungen benachrichtigen", + "push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", + "push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", + "push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.", + "push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.", + "push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden." }, "crew": { "title": "Skipper- & Crew-Profile", @@ -419,30 +447,14 @@ "loading": "Kalibrierungstabelle wird geladen..." }, "settings": { - "title": "Systemeinstellungen", - "subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.", - "owm_title": "Wetter-Integration", - "owm_key": "OpenWeatherMap API-Schlüssel", - "save": "Konfiguration speichern", - "saving": "Wird gespeichert...", - "saved": "Einstellungen erfolgreich gespeichert!", - "key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", - "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.", + "title": "Logbuch-Einstellungen", + "subtitle": "Teilen, Backup und Zusammenarbeit für dieses Logbuch.", + "select_logbook_hint": "Wähle ein Logbuch aus, um dessen Einstellungen zu bearbeiten.", + "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.", "weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.", "weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.", "gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.", - "theme_title": "Design-Anpassung", - "theme_label": "Design-Stil der App", - "theme_auto": "Automatisch (OS-Erkennung)", - "theme_ocean": "Ocean (Glassmorphismus)", - "theme_material": "Material (Android)", - "theme_cupertino": "Cupertino (iOS)", - "color_scheme_title": "Erscheinungsbild", - "color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)", - "color_scheme_auto": "Automatisch (System)", - "color_scheme_light": "Hell", - "color_scheme_dark": "Dunkel", "share_title": "Logbuch teilen (Schreibgeschützt)", "share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).", "share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.", @@ -459,17 +471,6 @@ "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", "deleting_account": "Konto wird gelöscht…", - "tour_title": "App-Tour", - "tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.", - "tour_restart": "Tour erneut starten", - "push_title": "Push-Benachrichtigungen", - "push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.", - "push_enable": "Bei Crew-Änderungen benachrichtigen", - "push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", - "push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", - "push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.", - "push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.", - "push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.", "backup_title": "Backup & Wiederherstellung", "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_export_title": "Backup erstellen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 9ff4e23..2562704 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -382,7 +382,35 @@ "stats_subtitle": "Across all your logbooks on this device", "stats_logbooks": "Logbooks", "stats_account_since": "Account since", - "stats_shared_logbooks": "Shared logbooks" + "stats_shared_logbooks": "Shared logbooks", + "appearance_title": "App & appearance", + "appearance_desc": "Theme and color scheme apply to the entire app on this device.", + "theme_label": "Application style / theme", + "theme_auto": "Auto (OS detect)", + "theme_ocean": "Ocean (glassmorphism)", + "theme_material": "Material (Android)", + "theme_cupertino": "Cupertino (iOS)", + "color_scheme_label": "Light or dark mode", + "color_scheme_auto": "Auto (system)", + "color_scheme_light": "Light", + "color_scheme_dark": "Dark", + "integrations_title": "Integrations", + "owm_key": "OpenWeatherMap API key", + "owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", + "prefs_save": "Save", + "prefs_saving": "Saving…", + "prefs_saved": "Saved", + "tour_title": "App tour", + "tour_desc": "Take a guided walkthrough of the main areas of the app again.", + "tour_restart": "Restart tour", + "push_title": "Push notifications", + "push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.", + "push_enable": "Notify on crew changes", + "push_active": "Push notifications are active on this device.", + "push_unsupported": "Push notifications are not supported in this browser.", + "push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.", + "push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.", + "push_error": "Could not enable push notifications." }, "crew": { "title": "Skipper & Crew Profiles", @@ -419,30 +447,14 @@ "loading": "Loading calibration table..." }, "settings": { - "title": "System Settings", - "subtitle": "Configure external integrations and client credentials.", - "owm_title": "Weather Integration", - "owm_key": "OpenWeatherMap API Key", - "save": "Save Configuration", - "saving": "Saving...", - "saved": "Settings saved successfully!", - "key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", - "no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.", + "title": "Logbook settings", + "subtitle": "Sharing, backup, and collaboration for this logbook.", + "select_logbook_hint": "Select a logbook to edit its settings.", + "no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.", "weather_success": "Weather details fetched successfully!", "weather_error": "Failed to fetch weather. Check your API key and connection.", "weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.", "gps_error": "Please enter a location or fetch GPS coordinates first.", - "theme_title": "UI Customization", - "theme_label": "Application Style / Theme", - "theme_auto": "Auto (OS Detect)", - "theme_ocean": "Ocean (Glassmorphism)", - "theme_material": "Material (Android)", - "theme_cupertino": "Cupertino (iOS)", - "color_scheme_title": "Appearance", - "color_scheme_label": "Light or dark mode (default: follow system)", - "color_scheme_auto": "Auto (System)", - "color_scheme_light": "Light", - "color_scheme_dark": "Dark", "share_title": "Share Logbook (Read-Only)", "share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).", "share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.", @@ -459,17 +471,6 @@ "delete_account_failed": "Failed to delete account. Please try again.", "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", "deleting_account": "Deleting account…", - "tour_title": "App tour", - "tour_desc": "Take a guided walkthrough of the main areas of the app again.", - "tour_restart": "Restart tour", - "push_title": "Push notifications", - "push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.", - "push_enable": "Notify on crew changes", - "push_active": "Push notifications are active on this device.", - "push_unsupported": "Push notifications are not supported in this browser.", - "push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.", - "push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.", - "push_error": "Could not enable push notifications.", "backup_title": "Backup & restore", "backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_export_title": "Create backup", diff --git a/client/src/services/appearance.ts b/client/src/services/appearance.ts index 7f40c18..91bc8a1 100644 --- a/client/src/services/appearance.ts +++ b/client/src/services/appearance.ts @@ -1,3 +1,5 @@ +import { getColorSchemePreference as getStoredColorScheme, getThemePreference } from './userPreferences.js' + export type ColorSchemePreference = 'auto' | 'light' | 'dark' export type ResolvedColorScheme = 'light' | 'dark' export type AppTheme = 'ocean' | 'material' | 'cupertino' @@ -6,7 +8,7 @@ const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as co const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const export function getColorSchemePreference(): ColorSchemePreference { - const stored = localStorage.getItem('active_color_scheme') + const stored = getStoredColorScheme() if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored return 'auto' } @@ -19,7 +21,7 @@ export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorS } export function resolveAppTheme(): AppTheme { - const configTheme = localStorage.getItem('active_theme') || 'auto' + const configTheme = getThemePreference() || 'auto' if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') { return configTheme } diff --git a/client/src/services/userPreferences.test.ts b/client/src/services/userPreferences.test.ts new file mode 100644 index 0000000..9a8b94e --- /dev/null +++ b/client/src/services/userPreferences.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getColorSchemePreference, + getOwmApiKey, + getThemePreference, + setColorSchemePreference, + setOwmApiKey, + setThemePreference +} from './userPreferences.js' + +const USER_ID = 'test-user-123' + +describe('userPreferences', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('migrates legacy theme and color scheme keys on first read', () => { + localStorage.setItem('active_userid', USER_ID) + localStorage.setItem('active_theme', 'material') + localStorage.setItem('active_color_scheme', 'dark') + + expect(getThemePreference()).toBe('material') + expect(getColorSchemePreference()).toBe('dark') + expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('material') + expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark') + }) + + it('stores OWM key per user', () => { + setOwmApiKey(USER_ID, 'secret-key') + expect(getOwmApiKey(USER_ID)).toBe('secret-key') + setOwmApiKey(USER_ID, ' ') + expect(getOwmApiKey(USER_ID)).toBe('') + }) + + it('writes theme preferences to namespaced keys', () => { + setThemePreference(USER_ID, 'ocean') + setColorSchemePreference(USER_ID, 'light') + expect(getThemePreference(USER_ID)).toBe('ocean') + expect(getColorSchemePreference(USER_ID)).toBe('light') + }) +}) diff --git a/client/src/services/userPreferences.ts b/client/src/services/userPreferences.ts new file mode 100644 index 0000000..b349f10 --- /dev/null +++ b/client/src/services/userPreferences.ts @@ -0,0 +1,86 @@ +const LEGACY_THEME = 'active_theme' +const LEGACY_COLOR_SCHEME = 'active_color_scheme' +const LEGACY_OWM_KEY = 'owm_api_key' + +function themeKey(userId: string): string { + return `user_pref_theme_${userId}` +} + +function colorSchemeKey(userId: string): string { + return `user_pref_color_scheme_${userId}` +} + +function owmKey(userId: string): string { + return `user_pref_owm_api_key_${userId}` +} + +export function getActiveUserId(): string | null { + return localStorage.getItem('active_userid') +} + +function migrateLegacyPrefs(userId: string): void { + const pairs: Array<{ namespaced: string; legacy: string }> = [ + { namespaced: themeKey(userId), legacy: LEGACY_THEME }, + { namespaced: colorSchemeKey(userId), legacy: LEGACY_COLOR_SCHEME }, + { namespaced: owmKey(userId), legacy: LEGACY_OWM_KEY } + ] + + for (const { namespaced, legacy } of pairs) { + if (localStorage.getItem(namespaced) != null) continue + const value = localStorage.getItem(legacy) + if (value != null) { + localStorage.setItem(namespaced, value) + } + } +} + +function resolveUserId(userId?: string | null): string | null { + const id = userId ?? getActiveUserId() + if (!id) return null + migrateLegacyPrefs(id) + return id +} + +export function getThemePreference(userId?: string | null): string { + const id = resolveUserId(userId) + if (id) { + return localStorage.getItem(themeKey(id)) ?? localStorage.getItem(LEGACY_THEME) ?? 'auto' + } + return localStorage.getItem(LEGACY_THEME) ?? 'auto' +} + +export function setThemePreference(userId: string, value: string): void { + migrateLegacyPrefs(userId) + localStorage.setItem(themeKey(userId), value) +} + +export function getColorSchemePreference(userId?: string | null): string { + const id = resolveUserId(userId) + if (id) { + return localStorage.getItem(colorSchemeKey(id)) ?? localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto' + } + return localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto' +} + +export function setColorSchemePreference(userId: string, value: string): void { + migrateLegacyPrefs(userId) + localStorage.setItem(colorSchemeKey(userId), value) +} + +export function getOwmApiKey(userId?: string | null): string { + const id = resolveUserId(userId) + if (id) { + return localStorage.getItem(owmKey(id)) ?? localStorage.getItem(LEGACY_OWM_KEY) ?? '' + } + return localStorage.getItem(LEGACY_OWM_KEY) ?? '' +} + +export function setOwmApiKey(userId: string, value: string): void { + migrateLegacyPrefs(userId) + const trimmed = value.trim() + if (trimmed) { + localStorage.setItem(owmKey(userId), trimmed) + } else { + localStorage.removeItem(owmKey(userId)) + } +} diff --git a/client/src/services/weather.ts b/client/src/services/weather.ts index c63a908..2dc4359 100644 --- a/client/src/services/weather.ts +++ b/client/src/services/weather.ts @@ -1,4 +1,5 @@ import { apiFetch } from './api.js' +import { getOwmApiKey } from './userPreferences.js' export class WeatherApiError extends Error { code: 'NO_KEY' | 'REQUEST_FAILED' @@ -26,7 +27,7 @@ export async function fetchOpenWeatherCurrent(params: { throw new WeatherApiError('lat/lon or location query required') } - const userKey = localStorage.getItem('owm_api_key')?.trim() + const userKey = getOwmApiKey().trim() const headers: Record = {} if (userKey) headers['X-OWM-Api-Key'] = userKey