Compare commits

...

5 Commits

Author SHA1 Message Date
elpatron d2833f7664 chore: release v0.1.0.65 2026-05-31 14:14:25 +02:00
elpatron 2a14080b5b fix(appearance): Theme-Sync an aufgelöste User-ID und Session koppeln
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:13:09 +02:00
elpatron 2457fa41e3 fix(profile): Profil-Statistiken auf Mobilgeräten platzsparender gestalten
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:11:57 +02:00
elpatron 87b0fa7bde chore: release v0.1.0.64 2026-05-31 14:03:27 +02:00
elpatron d90f292a21 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>
2026-05-31 14:03:17 +02:00
9 changed files with 312 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.64
0.1.0.66
+53
View File
@@ -3632,6 +3632,59 @@ html.theme-cupertino .events-scroll-container {
.stats-kpi-value {
font-size: 20px;
}
.profile-stats-section.form-card {
padding: 20px 16px;
}
.profile-stats-section .form-header {
margin-bottom: 12px;
gap: 10px;
}
.profile-stats-section .form-header h2 {
font-size: 18px;
}
.profile-stats-section .stats-subtitle {
font-size: 12px;
line-height: 1.35;
}
.profile-stats-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 0;
}
.profile-stats-kpi-grid .stats-kpi-card {
padding: 10px;
gap: 8px;
align-items: center;
}
.profile-stats-kpi-grid .stats-kpi-icon {
margin-top: 0;
}
.profile-stats-kpi-grid .stats-kpi-icon svg {
width: 16px;
height: 16px;
}
.profile-stats-kpi-grid .stats-kpi-label {
font-size: 11px;
margin-bottom: 2px;
line-height: 1.25;
}
.profile-stats-kpi-grid .stats-kpi-value {
font-size: 16px;
}
.profile-stats-kpi-grid .stats-kpi-unit {
font-size: 11px;
}
}
.signature-grid {
+8
View File
@@ -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)
+2 -2
View File
@@ -726,7 +726,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
</section>
<section className="form-card">
<section className="form-card profile-stats-section">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -736,7 +736,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
{(statsTotals || profile) && (
<div className="stats-kpi-grid">
<div className="stats-kpi-grid profile-stats-kpi-grid">
<KpiCard
icon={<BookOpen size={20} />}
label={t('profile.stats_logbooks')}
@@ -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) => {
@@ -0,0 +1,96 @@
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()
})
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
localStorage.setItem('active_userid', 'session-user')
setThemePreference('other-user', 'ocean')
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
persisted: true
})
await syncAppearancePrefs('other-user')
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
})
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
setThemePreference(USER_ID, 'ocean')
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
})
})
+76
View File
@@ -0,0 +1,76 @@
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
)
}
function resolveSyncedUserId(userId?: string | null): string | null {
const id = userId?.trim() || getActiveUserId()?.trim() || null
if (!id) return null
const activeId = getActiveUserId()?.trim() || null
if (!activeId || activeId !== id) return null
return id
}
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
}
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) 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 = resolveSyncedUserId(userId)
if (!id) return
try {
const server = await fetchAppearancePrefs(id)
if (server.persisted) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
}
notifyAppearanceChanged()
}
+10
View File
@@ -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
+62
View File
@@ -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({