fix(appearance): Theme-Einstellungen serverseitig speichern und beim Login wiederherstellen
Nach PWA-Cache-Löschung gingen Theme und Farbschema verloren, weil sie nur in localStorage lagen. Die Präferenzen werden jetzt synchronisiert und nach dem Login erneut angewendet. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
resolveColorScheme,
|
resolveColorScheme,
|
||||||
subscribeToSystemColorScheme
|
subscribeToSystemColorScheme
|
||||||
} from './services/appearance.js'
|
} from './services/appearance.js'
|
||||||
|
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.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(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx'
|
|||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||||
|
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
|
||||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
import {
|
import {
|
||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||||
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleThemeChange = (nextTheme: string) => {
|
const handleThemeChange = (nextTheme: string) => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<AppearancePrefs> {
|
||||||
|
if (!getActiveUserId()) {
|
||||||
|
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppearancePrefsToServer(theme: string, colorScheme: string): Promise<void> {
|
||||||
|
if (!getActiveUserId()) return
|
||||||
|
|
||||||
|
await apiJson<AppearancePrefs>(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<void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ model User {
|
|||||||
collaborations Collaboration[]
|
collaborations Collaboration[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
|
appearancePrefs UserAppearancePrefs?
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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 {
|
model Credential {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
|||||||
return trimmed.slice(0, 64)
|
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) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
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) => {
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
Reference in New Issue
Block a user