diff --git a/client/src/App.tsx b/client/src/App.tsx index a4e4761..c58d526 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -29,6 +29,7 @@ import { resolveColorScheme, subscribeToSystemColorScheme } from './services/appearance.js' +import { syncAppearancePrefs } from './services/appearancePrefs.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import DemoViewer from './components/DemoViewer.tsx' @@ -151,6 +152,13 @@ function App() { }) }, []) + useEffect(() => { + if (!isAuthenticated) return + const userId = localStorage.getItem('active_userid') + if (!userId) return + void syncAppearancePrefs(userId) + }, [isAuthenticated]) + useEffect(() => { const handleOnline = () => { setOnline(true) diff --git a/client/src/components/UserProfilePreferences.tsx b/client/src/components/UserProfilePreferences.tsx index f0b8f1a..252a716 100644 --- a/client/src/components/UserProfilePreferences.tsx +++ b/client/src/components/UserProfilePreferences.tsx @@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx' import { notifyAppearanceChanged } from '../services/appearance.js' +import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js' import { useAppTour } from '../context/AppTourContext.tsx' import { getColorSchemePreference, @@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference setThemePreference(userId, nextTheme) setColorSchemePreference(userId, nextColorScheme) notifyAppearanceChanged() + void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => { + console.warn('Failed to save appearance prefs to server:', err) + }) } const handleThemeChange = (nextTheme: string) => { diff --git a/client/src/services/appearancePrefs.test.ts b/client/src/services/appearancePrefs.test.ts new file mode 100644 index 0000000..87c8912 --- /dev/null +++ b/client/src/services/appearancePrefs.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchAppearancePrefs, + saveAppearancePrefsToServer, + syncAppearancePrefs +} from './appearancePrefs.js' +import { setThemePreference } from './userPreferences.js' + +const USER_ID = 'appearance-sync-user' + +vi.mock('./api.js', () => ({ + apiJson: vi.fn() +})) + +import { apiJson } from './api.js' + +const mockedApiJson = vi.mocked(apiJson) + +describe('appearancePrefs', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('fetchAppearancePrefs returns defaults when not authenticated', async () => { + await expect(fetchAppearancePrefs()).resolves.toEqual({ + theme: 'auto', + colorScheme: 'auto', + persisted: false + }) + expect(mockedApiJson).not.toHaveBeenCalled() + }) + + it('syncAppearancePrefs applies server prefs after cache wipe', async () => { + localStorage.setItem('active_userid', USER_ID) + mockedApiJson.mockResolvedValueOnce({ + theme: 'ocean', + colorScheme: 'dark', + persisted: true + }) + + const changed = vi.fn() + window.addEventListener('appearance-changed', changed) + + await syncAppearancePrefs(USER_ID) + + expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean') + expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark') + expect(changed).toHaveBeenCalledTimes(1) + }) + + it('syncAppearancePrefs uploads local prefs when server has none', async () => { + localStorage.setItem('active_userid', USER_ID) + setThemePreference(USER_ID, 'material') + mockedApiJson + .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false }) + .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true }) + + await syncAppearancePrefs(USER_ID) + + expect(mockedApiJson).toHaveBeenCalledTimes(2) + expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', { + method: 'PUT', + body: JSON.stringify({ theme: 'material', colorScheme: 'auto' }) + }) + }) + + it('saveAppearancePrefsToServer skips when not authenticated', async () => { + await saveAppearancePrefsToServer('ocean', 'light') + expect(mockedApiJson).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/services/appearancePrefs.ts b/client/src/services/appearancePrefs.ts new file mode 100644 index 0000000..4a24261 --- /dev/null +++ b/client/src/services/appearancePrefs.ts @@ -0,0 +1,62 @@ +import { apiJson } from './api.js' +import { notifyAppearanceChanged } from './appearance.js' +import { + getActiveUserId, + getColorSchemePreference, + getThemePreference, + setColorSchemePreference, + setThemePreference +} from './userPreferences.js' + +const API_BASE = '/api/auth/appearance-prefs' + +export interface AppearancePrefs { + theme: string + colorScheme: string + persisted: boolean +} + +function hasLocalAppearancePrefs(userId: string): boolean { + return ( + localStorage.getItem(`user_pref_theme_${userId}`) != null || + localStorage.getItem(`user_pref_color_scheme_${userId}`) != null + ) +} + +export async function fetchAppearancePrefs(): Promise { + if (!getActiveUserId()) { + return { theme: 'auto', colorScheme: 'auto', persisted: false } + } + + return apiJson(API_BASE) +} + +export async function saveAppearancePrefsToServer(theme: string, colorScheme: string): Promise { + if (!getActiveUserId()) return + + await apiJson(API_BASE, { + method: 'PUT', + body: JSON.stringify({ theme, colorScheme }) + }) +} + +/** Merge server-stored appearance with local cache (server wins after cache wipe). */ +export async function syncAppearancePrefs(userId?: string | null): Promise { + const id = userId?.trim() || getActiveUserId() + if (!id) return + + try { + const server = await fetchAppearancePrefs() + + if (server.persisted) { + setThemePreference(id, server.theme) + setColorSchemePreference(id, server.colorScheme) + } else if (hasLocalAppearancePrefs(id)) { + await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id)) + } + } catch (err) { + console.warn('Failed to sync appearance preferences:', err) + } + + notifyAppearanceChanged() +} diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e0dffcc..ab8b89f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { collaborations Collaboration[] pushSubscriptions PushSubscription[] notificationPrefs UserNotificationPrefs? + appearancePrefs UserAppearancePrefs? } model PushSubscription { @@ -48,6 +49,15 @@ model UserNotificationPrefs { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model UserAppearancePrefs { + userId String @id + theme String @default("auto") + colorScheme String @default("auto") + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Credential { id String @id @default(uuid()) userId String diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 5c1c07b..237fa38 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null { return trimmed.slice(0, 64) } +const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino']) +const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark']) + +function parseThemePreference(value: unknown): string | null { + return typeof value === 'string' && VALID_THEMES.has(value) ? value : null +} + +function parseColorSchemePreference(value: unknown): string | null { + return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null +} + router.post('/register-options', async (req, res) => { try { const { username } = req.body @@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => { } }) +router.get('/appearance-prefs', requireUser, async (req: any, res) => { + try { + const prefs = await prisma.userAppearancePrefs.findUnique({ + where: { userId: req.userId } + }) + + return res.json({ + theme: prefs?.theme ?? 'auto', + colorScheme: prefs?.colorScheme ?? 'auto', + persisted: prefs != null + }) + } catch (error: any) { + console.error('Error reading appearance prefs:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.put('/appearance-prefs', requireUser, async (req: any, res) => { + try { + const theme = parseThemePreference(req.body?.theme) + const colorScheme = parseColorSchemePreference(req.body?.colorScheme) + if (!theme || !colorScheme) { + return res.status(400).json({ error: 'Invalid theme or colorScheme' }) + } + + const prefs = await prisma.userAppearancePrefs.upsert({ + where: { userId: req.userId }, + create: { + userId: req.userId, + theme, + colorScheme, + updatedAt: new Date() + }, + update: { + theme, + colorScheme, + updatedAt: new Date() + } + }) + + return res.json({ + theme: prefs.theme, + colorScheme: prefs.colorScheme, + persisted: true + }) + } catch (error: any) { + console.error('Error updating appearance prefs:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + router.get('/profile', requireUser, async (req: any, res) => { try { const user = await prisma.user.findUnique({