mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): add theme persistence across devices
Introduce a user-preferences module and database schema to sync theme settings (theme, accent, radius, background, brightness) across devices. Users can now enable theme sync to persist their customization choices. Closes #146
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive, ref } from 'vue'
|
||||
import type { ThemePreferences } from '@bookorbit/types'
|
||||
|
||||
const user = ref<{ settings?: { syncThemePreferences?: boolean } } | null>(null)
|
||||
const toastError = vi.fn<(message: string) => void>()
|
||||
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
|
||||
function createThemeStore() {
|
||||
return reactive({
|
||||
theme: 'dark' as ThemePreferences['theme'],
|
||||
accent: 'blue' as ThemePreferences['accent'],
|
||||
radius: 'rounded' as ThemePreferences['radius'],
|
||||
background: 'vinyl' as ThemePreferences['background'],
|
||||
brightness: 35,
|
||||
setTheme(nextTheme: ThemePreferences['theme']) {
|
||||
this.theme = nextTheme
|
||||
},
|
||||
setAccent(nextAccent: ThemePreferences['accent']) {
|
||||
this.accent = nextAccent
|
||||
},
|
||||
setRadius(nextRadius: ThemePreferences['radius']) {
|
||||
this.radius = nextRadius
|
||||
},
|
||||
setBackground(nextBackground: ThemePreferences['background']) {
|
||||
this.background = nextBackground
|
||||
},
|
||||
setBrightness(nextBrightness: number) {
|
||||
this.brightness = nextBrightness
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let themeStore = createThemeStore()
|
||||
|
||||
function mockJsonResponse(body: unknown, ok = true) {
|
||||
return {
|
||||
ok,
|
||||
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
useAuth: () => ({ user }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
getAccessToken: () => 'test-access-token',
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { error: toastError },
|
||||
}))
|
||||
|
||||
describe('useThemeSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
user.value = { settings: { syncThemePreferences: false } }
|
||||
themeStore = createThemeStore()
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loadFromServer applies valid fields from the server payload', async () => {
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
settings: {
|
||||
theme: 'light',
|
||||
accent: 'rose',
|
||||
radius: 'pill',
|
||||
background: 'mesh',
|
||||
brightness: 72,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
await loadFromServer()
|
||||
|
||||
expect(themeStore.theme).toBe('light')
|
||||
expect(themeStore.accent).toBe('rose')
|
||||
expect(themeStore.radius).toBe('pill')
|
||||
expect(themeStore.background).toBe('mesh')
|
||||
expect(themeStore.brightness).toBe(72)
|
||||
})
|
||||
|
||||
it('loadFromServer no-ops when server settings are null', async () => {
|
||||
apiMock.mockResolvedValueOnce(mockJsonResponse({ settings: null }))
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
await loadFromServer()
|
||||
|
||||
expect(themeStore.theme).toBe('dark')
|
||||
expect(themeStore.accent).toBe('blue')
|
||||
expect(themeStore.radius).toBe('rounded')
|
||||
expect(themeStore.background).toBe('vinyl')
|
||||
expect(themeStore.brightness).toBe(35)
|
||||
})
|
||||
|
||||
it('loadFromServer swallows api errors', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
|
||||
await expect(loadFromServer()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('loadFromServer sanitizes invalid values and keeps existing local ones', async () => {
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
settings: {
|
||||
theme: 'light',
|
||||
accent: 'invalid',
|
||||
radius: 'pill',
|
||||
background: 'not-real',
|
||||
brightness: 101,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
await loadFromServer()
|
||||
|
||||
expect(themeStore.theme).toBe('light')
|
||||
expect(themeStore.accent).toBe('blue')
|
||||
expect(themeStore.radius).toBe('pill')
|
||||
expect(themeStore.background).toBe('vinyl')
|
||||
expect(themeStore.brightness).toBe(35)
|
||||
})
|
||||
|
||||
it('loadFromServer does not trigger a save request while applying server data', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ settings: { theme: 'light', accent: 'rose', radius: 'pill', background: 'mesh', brightness: 70 } }),
|
||||
)
|
||||
|
||||
const { initThemeSync, loadFromServer } = await import('../useThemeSync')
|
||||
initThemeSync()
|
||||
await loadFromServer()
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/theme')
|
||||
})
|
||||
|
||||
it('saveToServer calls PUT with the expected payload', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValueOnce(mockJsonResponse({}, true))
|
||||
const prefs: ThemePreferences = {
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
}
|
||||
|
||||
const { saveToServer } = await import('../useThemeSync')
|
||||
await saveToServer(prefs)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-preferences/theme',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings: prefs }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('saveToServer shows a toast when the server rejects the request', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValueOnce(mockJsonResponse({}, false))
|
||||
|
||||
const { saveToServer } = await import('../useThemeSync')
|
||||
await saveToServer({
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
})
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to save theme preferences')
|
||||
})
|
||||
|
||||
it('initThemeSync is idempotent', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
||||
|
||||
const { initThemeSync } = await import('../useThemeSync')
|
||||
initThemeSync()
|
||||
initThemeSync()
|
||||
|
||||
themeStore.setAccent('rose')
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('watcher only saves when sync is enabled', async () => {
|
||||
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
||||
|
||||
const { initThemeSync } = await import('../useThemeSync')
|
||||
initThemeSync()
|
||||
|
||||
themeStore.setAccent('rose')
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
themeStore.setAccent('green')
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-preferences/theme',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
accent: 'green',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cancelPendingThemeSync prevents a scheduled save from firing', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
||||
|
||||
const { initThemeSync, cancelPendingThemeSync } = await import('../useThemeSync')
|
||||
initThemeSync()
|
||||
|
||||
themeStore.setAccent('rose')
|
||||
cancelPendingThemeSync()
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, THEME_IDS, type ThemePreferences } from '@bookorbit/types'
|
||||
import { watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { api, getAccessToken } from '@/lib/api'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
let initialized = false
|
||||
let isApplyingServerPrefs = false
|
||||
let pendingSave: ReturnType<typeof setTimeout> | null = null
|
||||
let pagehideRegistered = false
|
||||
|
||||
function getCurrentPrefs(): ThemePreferences {
|
||||
const store = useThemeStore()
|
||||
|
||||
return {
|
||||
theme: store.theme,
|
||||
accent: store.accent,
|
||||
radius: store.radius,
|
||||
background: store.background,
|
||||
brightness: store.brightness,
|
||||
}
|
||||
}
|
||||
|
||||
function isSyncEnabled(): boolean {
|
||||
const { user } = useAuth()
|
||||
return user.value?.settings?.syncThemePreferences === true
|
||||
}
|
||||
|
||||
function sanitizeServerPrefs(raw: unknown): Partial<ThemePreferences> {
|
||||
if (typeof raw !== 'object' || raw === null) return {}
|
||||
|
||||
const obj = raw as Record<string, unknown>
|
||||
const out: Partial<ThemePreferences> = {}
|
||||
|
||||
if (THEME_IDS.includes(obj.theme as ThemePreferences['theme'])) out.theme = obj.theme as ThemePreferences['theme']
|
||||
if (ACCENT_IDS.includes(obj.accent as ThemePreferences['accent'])) out.accent = obj.accent as ThemePreferences['accent']
|
||||
if (RADIUS_IDS.includes(obj.radius as ThemePreferences['radius'])) out.radius = obj.radius as ThemePreferences['radius']
|
||||
if (BACKGROUND_IDS.includes(obj.background as ThemePreferences['background'])) out.background = obj.background as ThemePreferences['background']
|
||||
if (typeof obj.brightness === 'number' && Number.isInteger(obj.brightness) && obj.brightness >= 0 && obj.brightness <= 100) {
|
||||
out.brightness = obj.brightness
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function flushPendingSave(): void {
|
||||
if (pendingSave === null || !isSyncEnabled()) return
|
||||
|
||||
clearTimeout(pendingSave)
|
||||
pendingSave = null
|
||||
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) return
|
||||
|
||||
void fetch('/api/v1/user-preferences/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ settings: getCurrentPrefs() }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadFromServer(): Promise<void> {
|
||||
try {
|
||||
const res = await api('/api/v1/user-preferences/theme')
|
||||
if (!res.ok) return
|
||||
|
||||
const body = (await res.json()) as { settings: unknown }
|
||||
if (body.settings === null || body.settings === undefined) return
|
||||
|
||||
const prefs = sanitizeServerPrefs(body.settings)
|
||||
const store = useThemeStore()
|
||||
|
||||
isApplyingServerPrefs = true
|
||||
try {
|
||||
if (prefs.theme !== undefined) store.setTheme(prefs.theme)
|
||||
if (prefs.accent !== undefined) store.setAccent(prefs.accent)
|
||||
if (prefs.radius !== undefined) store.setRadius(prefs.radius)
|
||||
if (prefs.background !== undefined) store.setBackground(prefs.background)
|
||||
if (prefs.brightness !== undefined) store.setBrightness(prefs.brightness)
|
||||
} finally {
|
||||
isApplyingServerPrefs = false
|
||||
}
|
||||
} catch {
|
||||
// Silent on startup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedToServer(prefs: ThemePreferences): Promise<void> {
|
||||
try {
|
||||
await api('/api/v1/user-preferences/theme', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: prefs }),
|
||||
})
|
||||
} catch {
|
||||
// Silent seed failure.
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveToServer(prefs: ThemePreferences): Promise<void> {
|
||||
if (!isSyncEnabled()) return
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/user-preferences/theme', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: prefs }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to save theme preferences')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save theme preferences')
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPendingThemeSync(): void {
|
||||
if (pendingSave !== null) {
|
||||
clearTimeout(pendingSave)
|
||||
pendingSave = null
|
||||
}
|
||||
}
|
||||
|
||||
export function initThemeSync(): void {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const store = useThemeStore()
|
||||
|
||||
watch(
|
||||
() => [store.theme, store.accent, store.radius, store.background, store.brightness] as const,
|
||||
() => {
|
||||
if (isApplyingServerPrefs || !isSyncEnabled()) return
|
||||
|
||||
if (pendingSave !== null) clearTimeout(pendingSave)
|
||||
pendingSave = setTimeout(() => {
|
||||
pendingSave = null
|
||||
void saveToServer(getCurrentPrefs())
|
||||
}, 1500)
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
if (!pagehideRegistered && typeof window !== 'undefined') {
|
||||
pagehideRegistered = true
|
||||
window.addEventListener('pagehide', flushPendingSave)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ref } from 'vue'
|
||||
import type { AuthUser, AuthResponse } from '@bookorbit/types'
|
||||
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
|
||||
import router from '@/router'
|
||||
import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composables/useThemeSync'
|
||||
import { useSetupStatus } from './useSetupStatus'
|
||||
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
|
||||
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
|
||||
@@ -43,6 +44,7 @@ if (typeof document !== 'undefined') {
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
cancelPendingThemeSync()
|
||||
stopSessionRefresh()
|
||||
user.value = null
|
||||
setAccessToken(null)
|
||||
@@ -62,16 +64,30 @@ async function me(): Promise<void> {
|
||||
user.value = await res.json()
|
||||
}
|
||||
|
||||
async function hydrateThemeSync(options: { refreshUser?: boolean } = {}): Promise<void> {
|
||||
if (options.refreshUser) {
|
||||
await me()
|
||||
}
|
||||
|
||||
if (user.value?.settings?.syncThemePreferences) {
|
||||
await loadFromServer()
|
||||
}
|
||||
|
||||
initThemeSync()
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
async function init(): Promise<void> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await refreshAccessToken()
|
||||
await me()
|
||||
await hydrateThemeSync()
|
||||
startSessionRefresh()
|
||||
} catch {
|
||||
// no valid session
|
||||
} finally {
|
||||
initThemeSync()
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -92,9 +108,10 @@ export function useAuth() {
|
||||
const data: AuthResponse = await res.json()
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
await hydrateThemeSync({ refreshUser: true })
|
||||
startSessionRefresh()
|
||||
|
||||
if (data.user.isDefaultPassword) {
|
||||
if (user.value?.isDefaultPassword) {
|
||||
router.push('/')
|
||||
} else {
|
||||
const redirect = router.currentRoute.value.query.redirect as string | undefined
|
||||
@@ -128,6 +145,7 @@ export function useAuth() {
|
||||
const data: AuthResponse = await res.json()
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
await hydrateThemeSync()
|
||||
startSessionRefresh()
|
||||
|
||||
useSetupStatus().markSetupComplete()
|
||||
@@ -167,6 +185,7 @@ export function useAuth() {
|
||||
const data: AuthResponse = await res.json()
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
await hydrateThemeSync()
|
||||
startSessionRefresh()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ACCENT_VIVID, ACCENT_PASTEL, BACKGROUND_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Circle, Moon, Square, Sun } from 'lucide-vue-next'
|
||||
import { Circle, Cloud, Monitor, Moon, Square, Sun } from 'lucide-vue-next'
|
||||
import { loadFromServer, seedToServer } from '@/composables/useThemeSync'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
useDisplaySettings,
|
||||
type BookShadowStrength,
|
||||
@@ -15,6 +20,8 @@ import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { user } = useAuth()
|
||||
const { isDemoRestrictedAccount } = usePermissions()
|
||||
const {
|
||||
portraitCoverSize,
|
||||
squareCoverSize,
|
||||
@@ -32,11 +39,61 @@ const {
|
||||
|
||||
const { prefs, setPreference } = useSeriesCollapsePreference()
|
||||
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
|
||||
const syncEnabled = computed(() => !isDemoRestrictedAccount.value && (user.value?.settings?.syncThemePreferences ?? false))
|
||||
|
||||
async function handleGlobalCollapseToggle(value: boolean) {
|
||||
await setPreference('global', value)
|
||||
}
|
||||
|
||||
async function handleSetStorageMode(sync: boolean) {
|
||||
if (!user.value || syncEnabled.value === sync) return
|
||||
|
||||
if (isDemoRestrictedAccount.value) {
|
||||
toast.error('Demo-restricted account cannot change theme storage mode')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/users/me/theme-storage-mode', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sync }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to update storage mode')
|
||||
return
|
||||
}
|
||||
|
||||
user.value = {
|
||||
...user.value,
|
||||
settings: { ...user.value.settings, syncThemePreferences: sync },
|
||||
}
|
||||
|
||||
if (sync) {
|
||||
const prefsRes = await api('/api/v1/user-preferences/theme')
|
||||
if (prefsRes.ok) {
|
||||
const body = (await prefsRes.json()) as { settings: unknown }
|
||||
if (body.settings === null) {
|
||||
await seedToServer({
|
||||
theme: themeStore.theme,
|
||||
accent: themeStore.accent,
|
||||
radius: themeStore.radius,
|
||||
background: themeStore.background,
|
||||
brightness: themeStore.brightness,
|
||||
})
|
||||
} else {
|
||||
await loadFromServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
|
||||
} catch {
|
||||
toast.error('An error occurred while updating storage mode')
|
||||
}
|
||||
}
|
||||
|
||||
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
|
||||
{ key: 'progress-bar', label: 'Progress bar', hint: 'Thin colored line along the bottom edge' },
|
||||
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
|
||||
@@ -98,6 +155,69 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Preference storage -->
|
||||
<div class="mt-5 mb-6">
|
||||
<p class="settings-group-label">Where to save appearance preferences</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
|
||||
:class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
|
||||
@click="handleSetStorageMode(false)"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
|
||||
:class="!syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
<Monitor :size="16" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="settings-label">This device only</span>
|
||||
<span v-if="!syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-xs text-muted-foreground leading-relaxed">
|
||||
Preferences stay in your browser. Best if you want a different look on different devices.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 transition-colors"
|
||||
:class="[
|
||||
isDemoRestrictedAccount ? 'border-border bg-card opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
!isDemoRestrictedAccount && (syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'),
|
||||
]"
|
||||
@click="handleSetStorageMode(true)"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
|
||||
:class="syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
<Cloud :size="16" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="settings-label">My account</span>
|
||||
<span v-if="syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
|
||||
Active
|
||||
</span>
|
||||
<span
|
||||
v-if="isDemoRestrictedAccount"
|
||||
class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
Not available
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-xs text-muted-foreground leading-relaxed">
|
||||
Preferences are saved to your account. Best if you want the same appearance on every device.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme & colors -->
|
||||
<div class="mb-6">
|
||||
<p class="settings-group-label">Theme</p>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import AppearanceSettings from '../AppearanceSettings.vue'
|
||||
|
||||
const userState = {
|
||||
settings: { syncThemePreferences: false } as Record<string, unknown>,
|
||||
}
|
||||
|
||||
const permissionState = { isDemo: false }
|
||||
|
||||
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>>()
|
||||
const loadFromServerMock = vi.fn<() => Promise<void>>()
|
||||
const seedToServerMock = vi.fn<(...args: unknown[]) => Promise<void>>()
|
||||
|
||||
const themeStore = {
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
toggleTheme: vi.fn<() => void>(),
|
||||
setTheme: vi.fn<(theme: string) => void>(),
|
||||
setAccent: vi.fn<(accent: string) => void>(),
|
||||
setRadius: vi.fn<(radius: string) => void>(),
|
||||
setBackground: vi.fn<(background: string) => void>(),
|
||||
setBrightness: vi.fn<(brightness: number) => void>(),
|
||||
}
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
useAuth: () => {
|
||||
const user = ref({ ...userState, settings: { ...userState.settings } })
|
||||
return { user }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({
|
||||
isDemoRestrictedAccount: computed(() => permissionState.isDemo),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useThemeSync', () => ({
|
||||
loadFromServer: () => loadFromServerMock(),
|
||||
seedToServer: (...args: unknown[]) => seedToServerMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
ACCENT_VIVID: [{ id: 'blue', label: 'Blue', color: '#0000ff' }],
|
||||
ACCENT_PASTEL: [{ id: 'grey', label: 'Grey', color: '#999999' }],
|
||||
RADIUS_OPTIONS: [{ id: 'rounded', label: 'Rounded', className: 'rounded-lg' }],
|
||||
BACKGROUND_OPTIONS: [{ id: 'vinyl', label: 'Vinyl', previewClass: 'bg-muted' }],
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => ({
|
||||
portraitCoverSize: ref(180),
|
||||
squareCoverSize: ref(180),
|
||||
coverSizeScope: ref('separate'),
|
||||
portraitGridGap: ref(12),
|
||||
squareGridGap: ref(12),
|
||||
cardOverlays: ref([]),
|
||||
smartScopeFilterExpanded: ref(false),
|
||||
authorCoverSize: ref(120),
|
||||
authorCoverShape: ref('portrait'),
|
||||
tableZebraStriping: ref(true),
|
||||
bookSpineOverlay: ref('off'),
|
||||
bookShadowStrength: ref('default'),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
|
||||
useSeriesCollapsePreference: () => ({
|
||||
prefs: ref({ global: false }),
|
||||
setPreference: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>', props: ['asChild'] },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/ToggleSwitch.vue', () => ({
|
||||
default: { template: '<div />', props: ['modelValue', 'label', 'description'] },
|
||||
}))
|
||||
|
||||
vi.mock('../SettingsPageHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
|
||||
}))
|
||||
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
function mountComponent() {
|
||||
return mount(AppearanceSettings)
|
||||
}
|
||||
|
||||
describe('AppearanceSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
permissionState.isDemo = false
|
||||
userState.settings = { syncThemePreferences: false }
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => ({ settings: null }) })
|
||||
})
|
||||
|
||||
it('shows This device only as active when theme sync is disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
const deviceCard = cards[0]!
|
||||
expect(deviceCard.classes()).toContain('border-primary')
|
||||
expect(deviceCard.text()).toContain('Active')
|
||||
})
|
||||
|
||||
it('shows My account as active when theme sync is enabled', () => {
|
||||
userState.settings = { syncThemePreferences: true }
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
const accountCard = cards[1]!
|
||||
expect(accountCard.classes()).toContain('border-primary')
|
||||
expect(accountCard.text()).toContain('Active')
|
||||
})
|
||||
|
||||
it('enabling account sync seeds current local preferences when no server row exists', async () => {
|
||||
apiMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) }).mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
await cards[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/users/me/theme-storage-mode',
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ sync: true }) }),
|
||||
)
|
||||
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/user-preferences/theme')
|
||||
expect(seedToServerMock).toHaveBeenCalledWith({
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
})
|
||||
expect(loadFromServerMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Preferences will now be synced')
|
||||
})
|
||||
|
||||
it('enabling account sync loads existing server preferences when a row already exists', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: { theme: 'light' } }) })
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
await cards[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(loadFromServerMock).toHaveBeenCalled()
|
||||
expect(seedToServerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switching back to This device only calls the storage mode endpoint with sync false', async () => {
|
||||
userState.settings = { syncThemePreferences: true }
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
await cards[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/users/me/theme-storage-mode',
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ sync: false }) }),
|
||||
)
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Preferences will stay on this device')
|
||||
})
|
||||
|
||||
it('shows an error toast when the storage mode update fails', async () => {
|
||||
apiMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) })
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
await cards[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to update storage mode')
|
||||
})
|
||||
|
||||
it('demo-restricted users cannot enable account sync', async () => {
|
||||
permissionState.isDemo = true
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const cards = wrapper.findAll('.rounded-lg.border-2')
|
||||
await cards[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Not available')
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Demo-restricted account cannot change theme storage mode')
|
||||
})
|
||||
})
|
||||
@@ -1,67 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { BACKGROUND_IDS, type Background, ACCENT_IDS, type Accent, RADIUS_IDS, type Radius, THEME_IDS, type Theme } from '@bookorbit/types'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
export type Accent =
|
||||
| 'grey'
|
||||
// Vivid — rainbow order
|
||||
| 'rose'
|
||||
| 'orange'
|
||||
| 'amber'
|
||||
| 'yellow'
|
||||
| 'lime'
|
||||
| 'green'
|
||||
| 'emerald'
|
||||
| 'teal'
|
||||
| 'cyan'
|
||||
| 'sky'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'violet'
|
||||
| 'fuchsia'
|
||||
| 'pink'
|
||||
// Pastel — rainbow order
|
||||
| 'white'
|
||||
| 'coral'
|
||||
| 'peach'
|
||||
| 'butter'
|
||||
| 'lemon'
|
||||
| 'celadon'
|
||||
| 'sage'
|
||||
| 'mint'
|
||||
| 'seafoam'
|
||||
| 'powder'
|
||||
| 'mist'
|
||||
| 'periwinkle'
|
||||
| 'wisteria'
|
||||
| 'lavender'
|
||||
| 'orchid'
|
||||
| 'blush'
|
||||
export type Radius = 'sharp' | 'default' | 'rounded' | 'pill'
|
||||
export type Background =
|
||||
| 'none'
|
||||
| 'dots'
|
||||
| 'cross'
|
||||
| 'terminal'
|
||||
| 'millimeter'
|
||||
| 'blueprint'
|
||||
| 'brushed'
|
||||
| 'scanlines'
|
||||
| 'carbon'
|
||||
| 'vinyl'
|
||||
| 'perforated'
|
||||
| 'aurora'
|
||||
| 'horizon'
|
||||
| 'glow'
|
||||
| 'mesh'
|
||||
| 'elevation'
|
||||
| 'prism'
|
||||
| 'spectrum'
|
||||
| 'spectrum-x'
|
||||
| 'spectrum-plus'
|
||||
| 'eclipse'
|
||||
|
||||
export const ACCENT_VIVID: { id: Accent; label: string; color: string }[] = [
|
||||
{ id: 'white', label: 'White', color: '#fafafa' },
|
||||
{ id: 'rose', label: 'Rose', color: '#e11d48' },
|
||||
@@ -140,13 +81,11 @@ export const BACKGROUND_OPTIONS: { id: Background; label: string; cssClass: stri
|
||||
{ id: 'eclipse', label: 'Eclipse', cssClass: 'pattern-eclipse' },
|
||||
]
|
||||
|
||||
const ACCENT_IDS = ACCENT_OPTIONS.map((a) => a.id)
|
||||
const RADIUS_IDS = RADIUS_OPTIONS.map((r) => r.id)
|
||||
const BACKGROUND_IDS = BACKGROUND_OPTIONS.map((b) => b.id)
|
||||
const DEFAULT_SURFACE_BRIGHTNESS = 35
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const theme = ref<Theme>(storage.get<Theme>('theme', 'dark'))
|
||||
const storedTheme = storage.get<Theme>('theme', 'dark')
|
||||
const theme = ref<Theme>(THEME_IDS.includes(storedTheme) ? storedTheme : 'dark')
|
||||
|
||||
const storedAccent = storage.get<Accent>('accent', 'blue')
|
||||
const accent = ref<Accent>(ACCENT_IDS.includes(storedAccent) ? storedAccent : 'blue')
|
||||
@@ -178,8 +117,12 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
document.documentElement.style.setProperty('--bg-lift', lift.toFixed(4))
|
||||
}
|
||||
|
||||
function setTheme(nextTheme: Theme) {
|
||||
theme.value = nextTheme
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function setAccent(a: Accent) {
|
||||
@@ -248,5 +191,5 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return { theme, accent, radius, background, brightness, toggleTheme, setAccent, setRadius, setBackground, setBrightness }
|
||||
return { theme, accent, radius, background, brightness, setTheme, toggleTheme, setAccent, setRadius, setBackground, setBrightness }
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof Provis
|
||||
|
||||
export interface UserSettings {
|
||||
syncReaderPreferences?: boolean;
|
||||
syncThemePreferences?: boolean;
|
||||
statisticsConfig?: import("./statistics").StatisticsSettings;
|
||||
onboarding?: {
|
||||
tourCompleted?: boolean;
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface DuplicateScanResponse {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export type DuplicateScanState = 'idle' | 'computing' | 'done' | 'error';
|
||||
export type DuplicateScanState = "idle" | "computing" | "done" | "error";
|
||||
|
||||
export interface DuplicateScanStatus {
|
||||
entityType: EntityType;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./audit";
|
||||
export * from "./auth";
|
||||
export * from "./theme";
|
||||
export * from "./permissions";
|
||||
export * from "./content-filter";
|
||||
export * from "./metadata-preferences";
|
||||
|
||||
@@ -134,7 +134,10 @@ function normalizeResolverOptions(options?: PathResolverOptions): Required<PathR
|
||||
}
|
||||
|
||||
function sanitizePathSegmentValue(value: string, replacementCharacter: "_" | "-"): string {
|
||||
let sanitized = value.replace(INVALID_SEGMENT_CHARS_REGEX, replacementCharacter).trim().replace(/[. ]+$/g, "");
|
||||
let sanitized = value
|
||||
.replace(INVALID_SEGMENT_CHARS_REGEX, replacementCharacter)
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, "");
|
||||
if (!sanitized || sanitized === "." || sanitized === "..") {
|
||||
sanitized = replacementCharacter;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export const THEME_IDS = ["light", "dark"] as const;
|
||||
export type Theme = (typeof THEME_IDS)[number];
|
||||
|
||||
export const ACCENT_IDS = [
|
||||
"grey",
|
||||
"rose",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"green",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"fuchsia",
|
||||
"pink",
|
||||
"white",
|
||||
"coral",
|
||||
"peach",
|
||||
"butter",
|
||||
"lemon",
|
||||
"celadon",
|
||||
"sage",
|
||||
"mint",
|
||||
"seafoam",
|
||||
"powder",
|
||||
"mist",
|
||||
"periwinkle",
|
||||
"wisteria",
|
||||
"lavender",
|
||||
"orchid",
|
||||
"blush",
|
||||
] as const;
|
||||
export type Accent = (typeof ACCENT_IDS)[number];
|
||||
|
||||
export const RADIUS_IDS = ["sharp", "default", "rounded", "pill"] as const;
|
||||
export type Radius = (typeof RADIUS_IDS)[number];
|
||||
|
||||
export const BACKGROUND_IDS = [
|
||||
"none",
|
||||
"dots",
|
||||
"cross",
|
||||
"terminal",
|
||||
"millimeter",
|
||||
"blueprint",
|
||||
"brushed",
|
||||
"scanlines",
|
||||
"carbon",
|
||||
"vinyl",
|
||||
"perforated",
|
||||
"aurora",
|
||||
"horizon",
|
||||
"glow",
|
||||
"mesh",
|
||||
"elevation",
|
||||
"prism",
|
||||
"spectrum",
|
||||
"spectrum-x",
|
||||
"spectrum-plus",
|
||||
"eclipse",
|
||||
] as const;
|
||||
export type Background = (typeof BACKGROUND_IDS)[number];
|
||||
|
||||
export interface ThemePreferences {
|
||||
theme: Theme;
|
||||
accent: Accent;
|
||||
radius: Radius;
|
||||
background: Background;
|
||||
brightness: number;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { MetadataModule } from './modules/metadata/metadata.module';
|
||||
import { NarratorModule } from './modules/narrator/narrator.module';
|
||||
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
|
||||
import { RecommendationModule } from './modules/recommendation/recommendation.module';
|
||||
import { UserPreferencesModule } from './modules/user-preferences/user-preferences.module';
|
||||
import { ScannerModule } from './modules/scanner/scanner.module';
|
||||
import { SeedModule } from './modules/seed/seed.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
@@ -110,6 +111,7 @@ import { HardcoverModule } from './modules/hardcover/hardcover.module';
|
||||
HealthModule,
|
||||
CbzModule,
|
||||
ReaderPreferencesModule,
|
||||
UserPreferencesModule,
|
||||
EpubModule,
|
||||
BookDockModule,
|
||||
EmailModule,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "user_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"data" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "up_user_category_idx" ON "user_preferences" USING btree ("user_id","category");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
||||
"when": 1779941161092,
|
||||
"tag": "0008_auth_refresh_rotation_metadata",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1780159365997,
|
||||
"tag": "0009_user-preferences-theme-sync",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from './fonts';
|
||||
export * from './koreader';
|
||||
export * from './achievements';
|
||||
export * from './hardcover';
|
||||
export * from './user-preferences';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const userPreferences = pgTable(
|
||||
'user_preferences',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
category: text('category').notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [uniqueIndex('up_user_category_idx').on(t.userId, t.category)],
|
||||
);
|
||||
|
||||
export type UserPreference = typeof userPreferences.$inferSelect;
|
||||
export type NewUserPreference = typeof userPreferences.$inferInsert;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmptyObject, IsObject } from 'class-validator';
|
||||
|
||||
export class UpsertUserPreferenceDto {
|
||||
@IsObject()
|
||||
@IsNotEmptyObject()
|
||||
settings!: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Mocked } from 'vitest';
|
||||
import { EMPTY_CONTENT_FILTER_RULES, type ThemePreferences } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { UserPreferencesController } from './user-preferences.controller';
|
||||
import { UserPreferencesService } from './user-preferences.service';
|
||||
|
||||
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
|
||||
return {
|
||||
id: 7,
|
||||
username: 'reader',
|
||||
name: 'Reader',
|
||||
email: null,
|
||||
active: true,
|
||||
isSuperuser: false,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
permissions: [],
|
||||
...overrides,
|
||||
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||
};
|
||||
}
|
||||
|
||||
const validThemePreferences: ThemePreferences = {
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
};
|
||||
|
||||
describe('UserPreferencesController', () => {
|
||||
let service: Mocked<UserPreferencesService>;
|
||||
let controller: UserPreferencesController;
|
||||
|
||||
beforeEach(() => {
|
||||
service = {
|
||||
getThemePreferences: vi.fn(),
|
||||
upsertThemePreferences: vi.fn(),
|
||||
} as unknown as Mocked<UserPreferencesService>;
|
||||
|
||||
controller = new UserPreferencesController(service);
|
||||
});
|
||||
|
||||
it('GET /theme returns null settings when no saved preferences exist', async () => {
|
||||
service.getThemePreferences.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(controller.getThemePreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: null });
|
||||
expect(service.getThemePreferences).toHaveBeenCalledWith(11);
|
||||
});
|
||||
|
||||
it('GET /theme returns saved settings when present', async () => {
|
||||
service.getThemePreferences.mockResolvedValueOnce(validThemePreferences);
|
||||
|
||||
await expect(controller.getThemePreferences(makeUser())).resolves.toEqual({ settings: validThemePreferences });
|
||||
});
|
||||
|
||||
it('PUT /theme delegates valid payloads to the service and returns 204', async () => {
|
||||
const user = makeUser({ id: 42 });
|
||||
const dto = { settings: validThemePreferences };
|
||||
|
||||
await expect(controller.upsertThemePreferences(dto, user)).resolves.toBeUndefined();
|
||||
expect(service.upsertThemePreferences).toHaveBeenCalledWith(42, validThemePreferences);
|
||||
});
|
||||
|
||||
it('PUT /theme forwards the current user id to the service', async () => {
|
||||
const user = makeUser({ id: 99 });
|
||||
|
||||
await controller.upsertThemePreferences({ settings: validThemePreferences }, user);
|
||||
|
||||
expect(service.upsertThemePreferences).toHaveBeenCalledWith(99, validThemePreferences);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Get, HttpCode, Put } from '@nestjs/common';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { UpsertUserPreferenceDto } from './dto/upsert-user-preference.dto';
|
||||
import { UserPreferencesService } from './user-preferences.service';
|
||||
|
||||
@Controller('user-preferences')
|
||||
export class UserPreferencesController {
|
||||
constructor(private readonly userPreferencesService: UserPreferencesService) {}
|
||||
|
||||
@Get('theme')
|
||||
async getThemePreferences(@CurrentUser() user: RequestUser) {
|
||||
const settings = await this.userPreferencesService.getThemePreferences(user.id);
|
||||
return { settings };
|
||||
}
|
||||
|
||||
@Put('theme')
|
||||
@HttpCode(204)
|
||||
async upsertThemePreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
|
||||
await this.userPreferencesService.upsertThemePreferences(user.id, dto.settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DbModule } from '../../db/db.module';
|
||||
import { UserPreferencesController } from './user-preferences.controller';
|
||||
import { UserPreferencesRepository } from './user-preferences.repository';
|
||||
import { UserPreferencesService } from './user-preferences.service';
|
||||
|
||||
@Module({
|
||||
imports: [DbModule],
|
||||
controllers: [UserPreferencesController],
|
||||
providers: [UserPreferencesService, UserPreferencesRepository],
|
||||
})
|
||||
export class UserPreferencesModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class UserPreferencesRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
findByCategory(userId: number, category: string) {
|
||||
return this.db.query.userPreferences.findFirst({
|
||||
where: and(eq(schema.userPreferences.userId, userId), eq(schema.userPreferences.category, category)),
|
||||
});
|
||||
}
|
||||
|
||||
async upsert(userId: number, category: string, data: Record<string, unknown>) {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.insert(schema.userPreferences)
|
||||
.values({ userId, category, data, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.userPreferences.userId, schema.userPreferences.category],
|
||||
set: { data, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(userId: number, category: string) {
|
||||
await this.db.delete(schema.userPreferences).where(and(eq(schema.userPreferences.userId, userId), eq(schema.userPreferences.category, category)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ThemePreferences } from '@bookorbit/types';
|
||||
|
||||
import { UserPreferencesRepository } from './user-preferences.repository';
|
||||
import { UserPreferencesService } from './user-preferences.service';
|
||||
|
||||
const validThemePreferences: ThemePreferences = {
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
};
|
||||
|
||||
const repo = {
|
||||
findByCategory: vi.fn<(...args: [number, string]) => Promise<{ data: ThemePreferences } | undefined>>(),
|
||||
upsert: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
|
||||
delete: vi.fn<(...args: [number, string]) => Promise<void>>(),
|
||||
};
|
||||
|
||||
describe('UserPreferencesService', () => {
|
||||
let service: UserPreferencesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.findByCategory.mockResolvedValue(undefined);
|
||||
repo.upsert.mockResolvedValue(undefined);
|
||||
service = new UserPreferencesService(repo as unknown as UserPreferencesRepository);
|
||||
});
|
||||
|
||||
it('getThemePreferences returns null when repository has no row', async () => {
|
||||
await expect(service.getThemePreferences(7)).resolves.toBeNull();
|
||||
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'theme');
|
||||
});
|
||||
|
||||
it('getThemePreferences returns saved theme settings when row exists', async () => {
|
||||
repo.findByCategory.mockResolvedValueOnce({ data: validThemePreferences });
|
||||
|
||||
await expect(service.getThemePreferences(7)).resolves.toEqual(validThemePreferences);
|
||||
});
|
||||
|
||||
it('upsertThemePreferences persists validated settings', async () => {
|
||||
await expect(service.upsertThemePreferences(11, validThemePreferences)).resolves.toBeUndefined();
|
||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects invalid theme ids', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, theme: 'sepia' } as never)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects invalid accent ids', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, accent: 'magenta' } as never)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects invalid radius ids', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, radius: 'soft' } as never)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects invalid background ids', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, background: 'stars' } as never)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects brightness below zero', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: -1 })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects brightness above one hundred', async () => {
|
||||
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: 101 })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects extra unknown fields', async () => {
|
||||
await expect(
|
||||
service.upsertThemePreferences(11, { ...validThemePreferences, unexpected: true } as Record<string, unknown>),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsertThemePreferences rejects payloads missing required fields', async () => {
|
||||
const { background, ...incomplete } = validThemePreferences;
|
||||
void background;
|
||||
|
||||
await expect(service.upsertThemePreferences(11, incomplete)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, THEME_IDS, type ThemePreferences } from '@bookorbit/types';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { UserPreferencesRepository } from './user-preferences.repository';
|
||||
|
||||
const THEME_PREFERENCES_SCHEMA = z
|
||||
.object({
|
||||
theme: z.enum(THEME_IDS),
|
||||
accent: z.enum(ACCENT_IDS),
|
||||
radius: z.enum(RADIUS_IDS),
|
||||
background: z.enum(BACKGROUND_IDS),
|
||||
brightness: z.number().int().min(0).max(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@Injectable()
|
||||
export class UserPreferencesService {
|
||||
private readonly logger = new Logger(UserPreferencesService.name);
|
||||
|
||||
constructor(private readonly repo: UserPreferencesRepository) {}
|
||||
|
||||
async getThemePreferences(userId: number): Promise<ThemePreferences | null> {
|
||||
const row = await this.repo.findByCategory(userId, 'theme');
|
||||
return row ? (row.data as ThemePreferences) : null;
|
||||
}
|
||||
|
||||
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
|
||||
const start = Date.now();
|
||||
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
|
||||
|
||||
const result = THEME_PREFERENCES_SCHEMA.safeParse(data);
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues[0];
|
||||
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
|
||||
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
|
||||
throw new BadRequestException(`Invalid theme preferences at "${issuePath}": ${issueMessage}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.repo.upsert(userId, 'theme', result.data);
|
||||
const durationMs = Date.now() - start;
|
||||
this.logger.log(`[user_preferences.upsert_theme] [end] userId=${userId} durationMs=${durationMs} - upsert theme preferences completed`);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - start;
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.error(
|
||||
`[user_preferences.upsert_theme] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${error}" - upsert theme preferences failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateThemeStorageModeDto {
|
||||
@IsBoolean()
|
||||
sync!: boolean;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ describe('UserController', () => {
|
||||
updateMe: vi.fn(),
|
||||
updateMySettings: vi.fn(),
|
||||
updateReaderStorageMode: vi.fn(),
|
||||
updateThemeStorageMode: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
@@ -293,4 +294,23 @@ describe('UserController', () => {
|
||||
message: 'Demo-restricted account cannot change reader storage mode',
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates theme storage mode update to updateThemeStorageMode service method', async () => {
|
||||
userService.updateThemeStorageMode.mockResolvedValue({ id: 7, settings: { syncThemePreferences: true } });
|
||||
|
||||
await controller.updateThemeStorageMode({ id: 7 } as any, { sync: true } as any);
|
||||
|
||||
expect(userService.updateThemeStorageMode).toHaveBeenCalledWith(7, true);
|
||||
});
|
||||
|
||||
it('updateThemeStorageMode has ForbidPermission DemoRestricted metadata', () => {
|
||||
const meta = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, UserController.prototype.updateThemeStorageMode) as {
|
||||
permission: Permission;
|
||||
message?: string;
|
||||
};
|
||||
expect(meta).toEqual({
|
||||
permission: Permission.DemoRestricted,
|
||||
message: 'Demo-restricted account cannot change theme storage mode',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import { SetContentFiltersDto } from './dto/set-content-filters.dto';
|
||||
import { UpdateMeDto } from './dto/update-me.dto';
|
||||
import { UpdateMeSettingsDto } from './dto/update-me-settings.dto';
|
||||
import { UpdateReaderStorageModeDto } from './dto/update-reader-storage-mode.dto';
|
||||
import { UpdateThemeStorageModeDto } from './dto/update-theme-storage-mode.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse-preferences.dto';
|
||||
import { MAX_USER_AVATAR_BYTES } from './user-avatar.service';
|
||||
@@ -91,6 +92,13 @@ export class UserController {
|
||||
return this.userService.updateReaderStorageMode(user.id, dto.sync);
|
||||
}
|
||||
|
||||
@Patch('me/theme-storage-mode')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot change theme storage mode')
|
||||
updateThemeStorageMode(@CurrentUser() user: RequestUser, @Body() dto: UpdateThemeStorageModeDto) {
|
||||
return this.userService.updateThemeStorageMode(user.id, dto.sync);
|
||||
}
|
||||
|
||||
@Patch('me/series-collapse-preferences')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
updateSeriesCollapsePreferences(@CurrentUser() user: RequestUser, @Body() dto: UpdateSeriesCollapsePreferencesDto) {
|
||||
|
||||
@@ -273,6 +273,23 @@ describe('UserService', () => {
|
||||
await expect(service.updateReaderStorageMode(99, true)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('updateThemeStorageMode updates syncThemePreferences when enabling sync', async () => {
|
||||
userRepo.update.mockResolvedValue({ id: 3, settings: { syncThemePreferences: true } });
|
||||
|
||||
const result = await service.updateThemeStorageMode(3, true);
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(3, { settings: { syncThemePreferences: true } });
|
||||
expect(result).toMatchObject({ id: 3 });
|
||||
});
|
||||
|
||||
it('updateThemeStorageMode updates syncThemePreferences when disabling sync', async () => {
|
||||
userRepo.update.mockResolvedValue({ id: 3, settings: { syncThemePreferences: false } });
|
||||
|
||||
await service.updateThemeStorageMode(3, false);
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(3, { settings: { syncThemePreferences: false } });
|
||||
});
|
||||
|
||||
it('deleteUser blocks deleting your own account', async () => {
|
||||
await expect(service.deleteUser(1, reqUser({ id: 1, isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
@@ -198,6 +198,12 @@ export class UserService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateThemeStorageMode(userId: number, sync: boolean) {
|
||||
const user = await this.userRepo.update(userId, { settings: { syncThemePreferences: sync } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUser(id: number, requestingUser: RequestUser) {
|
||||
if (id === requestingUser.id) {
|
||||
throw new ConflictException('You cannot delete your own account');
|
||||
|
||||
Reference in New Issue
Block a user