import type { UserSettingsStore, UserSettingsStoreConfig, GlobalSettings, AppOverride, NavSettings, ThemeSettings, UserSettingsResponse, GeneralSettings, } 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'; /** * 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 } = config; const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`; // State let globalSettings = $state({ ...DEFAULT_GLOBAL_SETTINGS }); let appOverrides = $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, 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; } 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 || {}; 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, }, }, }; saveToStorage(); syncing = true; try { const data = await apiRequest( 'PATCH', '/global', settings ); if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; 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 || {}; 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 || {}; saveToStorage(); } else { // Rollback on failure appOverrides = previousOverrides; 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; }, load, updateGlobal, updateAppOverride, removeAppOverride, setStartPage, updateGeneral, }; }