import type { UserSettingsStore, UserSettingsStoreConfig, GlobalSettings, AppOverride, NavSettings, ThemeSettings, UserSettingsResponse, GeneralSettings, DeviceAppSettings, DeviceInfo, DeviceType, DevicesListResponse, } from './types'; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types'; import { isBrowser } from './utils'; import { getStartPage as getStartPageFromConfig } from './app-routes'; const STORAGE_KEY_PREFIX = 'manacore-user-settings'; const DEVICE_ID_KEY = 'manacore-device-id'; /** * Generate a unique device ID */ function generateDeviceId(): string { return 'dev_' + crypto.randomUUID().replace(/-/g, '').substring(0, 16); } /** * Get or create device ID from localStorage */ function getOrCreateDeviceId(): string { if (!isBrowser()) return 'server'; try { let deviceId = localStorage.getItem(DEVICE_ID_KEY); if (!deviceId) { deviceId = generateDeviceId(); localStorage.setItem(DEVICE_ID_KEY, deviceId); } return deviceId; } catch { return generateDeviceId(); } } /** * Detect device type based on user agent and screen size */ function detectDeviceType(): DeviceType { if (!isBrowser()) return 'desktop'; const ua = navigator.userAgent.toLowerCase(); const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(ua); const isTablet = /tablet|ipad|android(?!.*mobile)/i.test(ua); if (isTablet) return 'tablet'; if (isMobile) return 'mobile'; return 'desktop'; } /** * Detect device name based on user agent */ function detectDeviceName(): string { if (!isBrowser()) return 'Server'; const ua = navigator.userAgent; // Try to extract device/browser info if (/iPhone/.test(ua)) return 'iPhone'; if (/iPad/.test(ua)) return 'iPad'; if (/Android/.test(ua)) { const match = ua.match(/Android.*;\s*([^;)]+)/); if (match) return match[1].trim(); return 'Android Gerät'; } if (/Mac/.test(ua)) return 'Mac'; if (/Windows/.test(ua)) return 'Windows PC'; if (/Linux/.test(ua)) return 'Linux PC'; return 'Unbekanntes Gerät'; } /** * Create a User Settings store for your app * * This store syncs settings with mana-core-auth and provides: * - Global settings that apply to all apps * - Per-app overrides for customization * - localStorage caching for offline support * * @example * ```typescript * import { createUserSettingsStore } from '@manacore/shared-theme'; * * export const userSettings = createUserSettingsStore({ * appId: 'calendar', * authUrl: 'http://localhost:3001', * getAccessToken: () => authStore.getAccessToken() * }); * * // In +layout.svelte * $effect(() => { * if (authStore.isAuthenticated) { * userSettings.load(); * } * }); * ``` */ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSettingsStore { const { appId, authUrl, getAccessToken, deviceName, deviceType } = config; const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`; // Device info (initialized once) const deviceId = getOrCreateDeviceId(); const detectedDeviceType = deviceType || detectDeviceType(); const detectedDeviceName = deviceName || detectDeviceName(); // State let globalSettings = $state({ ...DEFAULT_GLOBAL_SETTINGS }); let appOverrides = $state>({}); let deviceSettings = $state>({}); let syncing = $state(false); let loaded = $state(false); // Derived: resolved nav settings (global + app override) const nav = $derived({ ...globalSettings.nav, ...(appOverrides[appId]?.nav || {}), }); // Derived: resolved theme settings (global + app override) const theme = $derived({ ...globalSettings.theme, ...(appOverrides[appId]?.theme || {}), }); // Derived: current locale const locale = $derived(globalSettings.locale); // Derived: whether this app has an override const hasAppOverride = $derived(!!appOverrides[appId]); // Derived: resolved general settings (always from global) const general = $derived({ ...DEFAULT_GENERAL_SETTINGS, ...globalSettings.general, }); // Derived: start page for current app const startPage = $derived(getStartPageFromConfig(appId, general.startPages)); /** * Save current settings to localStorage (for offline fallback) */ function saveToStorage(): void { if (!isBrowser()) return; try { localStorage.setItem( storageKey, JSON.stringify({ globalSettings, appOverrides, deviceSettings, timestamp: Date.now(), }) ); } catch (e) { console.error('Failed to save user settings to storage:', e); } } /** * Load settings from localStorage (fallback) */ function loadFromStorage(): boolean { if (!isBrowser()) return false; try { const stored = localStorage.getItem(storageKey); if (stored) { const data = JSON.parse(stored); if (data.globalSettings) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; } if (data.appOverrides) { appOverrides = data.appOverrides; } if (data.deviceSettings) { deviceSettings = data.deviceSettings; } return true; } } catch (e) { console.error('Failed to load user settings from storage:', e); } return false; } /** * Make an API request to the settings endpoint */ async function apiRequest(method: string, path: string, body?: object): Promise { const token = await getAccessToken(); if (!token) { console.warn('No access token available for settings API'); return null; } try { const response = await fetch(`${authUrl}/api/v1/settings${path}`, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { console.error(`Settings API error: ${response.status}`); return null; } return await response.json(); } catch (e) { console.error('Settings API request failed:', e); return null; } } /** * Load settings from server */ async function load(): Promise { // Load from cache first for instant UI loadFromStorage(); syncing = true; try { const data = await apiRequest('GET', ''); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); loaded = true; } } finally { syncing = false; } } /** * Update global settings */ async function updateGlobal(settings: Partial): Promise { // Optimistic update const previousGlobal = { ...globalSettings }; globalSettings = { nav: { ...globalSettings.nav, ...settings.nav }, theme: { ...globalSettings.theme, ...settings.theme }, locale: settings.locale ?? globalSettings.locale, general: { ...globalSettings.general, ...settings.general, startPages: { ...globalSettings.general?.startPages, ...settings.general?.startPages, }, }, recentEmojis: settings.recentEmojis ?? globalSettings.recentEmojis, }; saveToStorage(); syncing = true; try { const data = await apiRequest( 'PATCH', '/global', settings ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure globalSettings = previousGlobal; saveToStorage(); } } finally { syncing = false; } } /** * Update app-specific override */ async function updateAppOverride(settings: AppOverride): Promise { // Optimistic update const previousOverrides = { ...appOverrides }; appOverrides = { ...appOverrides, [appId]: { ...appOverrides[appId], ...settings, }, }; saveToStorage(); syncing = true; try { const data = await apiRequest( 'PATCH', `/app/${appId}`, settings ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure appOverrides = previousOverrides; saveToStorage(); } } finally { syncing = false; } } /** * Update start page for a specific app */ async function setStartPage(targetAppId: string, path: string): Promise { await updateGlobal({ general: { startPages: { [targetAppId]: path, }, }, } as Partial); } /** * Update general settings */ async function updateGeneral(settings: Partial): Promise { await updateGlobal({ general: { ...globalSettings.general, ...settings, startPages: { ...globalSettings.general?.startPages, ...settings.startPages, }, }, }); } /** * Remove app override (revert to global settings) */ async function removeAppOverride(): Promise { // Optimistic update const previousOverrides = { ...appOverrides }; const newOverrides = { ...appOverrides }; delete newOverrides[appId]; appOverrides = newOverrides; saveToStorage(); syncing = true; try { const data = await apiRequest( 'DELETE', `/app/${appId}` ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure appOverrides = previousOverrides; saveToStorage(); } } finally { syncing = false; } } /** * Get hidden nav items for a specific app */ function getHiddenNavItemsForApp(targetAppId: string): string[] { return globalSettings.nav.hiddenNavItems?.[targetAppId] || []; } /** * Toggle visibility of a navigation item for an app */ async function toggleNavItemVisibility(targetAppId: string, href: string): Promise { const currentHidden = getHiddenNavItemsForApp(targetAppId); const isHidden = currentHidden.includes(href); const newHidden = isHidden ? currentHidden.filter((h) => h !== href) : [...currentHidden, href]; await setHiddenNavItems(targetAppId, newHidden); } /** * Set hidden nav items for an app */ async function setHiddenNavItems(targetAppId: string, hiddenHrefs: string[]): Promise { const newHiddenNavItems = { ...globalSettings.nav.hiddenNavItems, [targetAppId]: hiddenHrefs, }; // Remove empty arrays if (hiddenHrefs.length === 0) { delete newHiddenNavItems[targetAppId]; } await updateGlobal({ nav: { hiddenNavItems: newHiddenNavItems, }, } as Partial); } // ============================================================================ // Device Settings Functions // ============================================================================ /** * Update device-specific app settings for current device */ async function updateDeviceAppSettings(settings: Record): Promise { // Optimistic update const previousDeviceSettings = { ...deviceSettings }; const existingDevice = deviceSettings[deviceId] || { deviceName: detectedDeviceName, deviceType: detectedDeviceType, lastSeen: new Date().toISOString(), apps: {}, }; deviceSettings = { ...deviceSettings, [deviceId]: { ...existingDevice, lastSeen: new Date().toISOString(), apps: { ...existingDevice.apps, [appId]: { ...(existingDevice.apps?.[appId] || {}), ...settings, }, }, }, }; saveToStorage(); syncing = true; try { const data = await apiRequest( 'PATCH', `/device/${deviceId}/${appId}`, { deviceName: detectedDeviceName, deviceType: detectedDeviceType, settings, } ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure deviceSettings = previousDeviceSettings; saveToStorage(); } } finally { syncing = false; } } /** * Get device-specific app settings for current device */ function getDeviceAppSettings(): Record { const device = deviceSettings[deviceId]; if (!device?.apps?.[appId]) return {}; return device.apps[appId]; } /** * Get list of all devices */ async function getDevices(): Promise { const data = await apiRequest('GET', '/devices'); if (data?.success) { return data.devices; } return []; } /** * Remove a device */ async function removeDevice(targetDeviceId: string): Promise { syncing = true; try { const data = await apiRequest( 'DELETE', `/device/${targetDeviceId}` ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; deviceSettings = data.deviceSettings || {}; saveToStorage(); } } finally { syncing = false; } } return { get nav() { return nav; }, get theme() { return theme; }, get locale() { return locale; }, get general() { return general; }, get startPage() { return startPage; }, get globalSettings() { return globalSettings; }, get hasAppOverride() { return hasAppOverride; }, get syncing() { return syncing; }, get loaded() { return loaded; }, get deviceId() { return deviceId; }, get deviceSettings() { return deviceSettings; }, get currentDeviceAppSettings() { const device = deviceSettings[deviceId]; if (!device?.apps?.[appId]) return {}; return device.apps[appId]; }, load, updateGlobal, updateAppOverride, removeAppOverride, setStartPage, updateGeneral, getHiddenNavItemsForApp, toggleNavItemVisibility, setHiddenNavItems, updateDeviceAppSettings, getDeviceAppSettings, getDevices, removeDevice, }; }