Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2833f7664 | |||
| 2a14080b5b | |||
| 2457fa41e3 | |||
| 87b0fa7bde | |||
| d90f292a21 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user