feat(quick-261016-0fm): manual theme switch on the rail

- the rail's top button cycles system, light and dark and names the
  current choice; light and dark ignore prefers-color-scheme
- usePreferences applies the cached theme before the first await of boot,
  saves the choice through PUT /account/preferences and reverts with a
  toast when the save fails; me() applies the stored preference
This commit is contained in:
Jakub Zych
2026-10-16 09:54:02 +02:00
parent 047858ae84
commit 21b0d39de0
7 changed files with 181 additions and 17 deletions

View File

@@ -5,6 +5,7 @@ import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import { api, onRefreshed, refreshSession } from '../api/client'
import type { AdminProfile } from '../api/types'
import { setListStateOwner } from './listState'
import { applyProfilePreferences } from './usePreferences'
import { setNavigation } from './useNavigation'
import { setSettings } from './useSettings'
@@ -52,11 +53,13 @@ export async function login(identifier: string, password: string): Promise<boole
* Loads the signed-in admin; null when the session is missing or expired.
* The stored list queries follow the answer: a profile keeps only that
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
* The profile's UI preferences (theme) are applied before it returns.
*/
export async function me(): Promise<AdminProfile | null> {
const { data, response } = await api.GET('/auth/me')
user.value = response.ok && data ? data.data : null
setListStateOwner(user.value?.id ?? null)
await applyProfilePreferences(user.value)
return user.value
}

View File

@@ -0,0 +1,124 @@
// The signed-in admin's UI preferences: the theme (system, light or dark).
// The server keeps them per admin (GET /auth/me preferences, PUT
// /account/preferences); the last choice is also cached in localStorage under
// THEME_KEY, so boot applies it before the first await and the shell never
// renders in the wrong theme. Only the choice's code is stored; tokens and
// record data never are (T-261016-0fm-08).
import { readonly, ref } from 'vue'
import { api } from '../api/client'
import type { AdminProfile } from '../api/types'
import { t } from '../app/i18n'
import { applyColorScheme, isThemeChoice, THEME_CHOICES, type ThemeChoice } from '../app/theme'
import { showToast } from './useToasts'
export const THEME_KEY = 'summer-admin.theme'
const theme = ref<ThemeChoice>('system')
let stopTheme: () => void = () => undefined
/** The theme currently applied. */
export const themeChoice = readonly(theme)
function storage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
function cache(key: string, value: string | null): void {
try {
if (value === null) {
storage()?.removeItem(key)
} else {
storage()?.setItem(key, value)
}
} catch {
// Storage may be full or disabled; the choice still holds for the session.
}
}
/** Caches a theme; system, the default, is stored as an absent key. */
function cacheTheme(choice: ThemeChoice): void {
cache(THEME_KEY, choice === 'system' ? null : choice)
}
/** The cached theme when it is a valid choice, else system. */
export function cachedTheme(): ThemeChoice {
try {
const value = storage()?.getItem(THEME_KEY)
return isThemeChoice(value) ? value : 'system'
} catch {
return 'system'
}
}
/** Applies a theme, replacing the previous one's media subscription. */
export function applyTheme(choice: ThemeChoice): void {
stopTheme()
theme.value = choice
stopTheme = typeof window === 'undefined' ? () => undefined : applyColorScheme(window, choice)
}
/** The choice after current in the rail button's cycle. */
export function nextTheme(current: ThemeChoice): ThemeChoice {
const index = THEME_CHOICES.indexOf(current)
return THEME_CHOICES[(index + 1) % THEME_CHOICES.length] ?? 'system'
}
/**
* Applies and caches a theme at once and saves it on the server. When the
* save fails the previous theme comes back and a danger toast says so.
*/
export async function setTheme(choice: ThemeChoice): Promise<void> {
const previous = theme.value
applyTheme(choice)
cacheTheme(choice)
let ok = false
try {
const { response } = await api.PUT('/account/preferences', { body: { theme: choice } })
ok = response.ok
} catch {
ok = false
}
if (ok) {
return
}
// A later click owns the theme now; only this request's choice is undone.
if (theme.value === choice) {
applyTheme(previous)
cacheTheme(previous)
}
showToast(t('backend::lang.myaccount.preferences_failed'), 'danger')
}
/** Moves to the next theme: system, light, dark, system. */
export function cycleTheme(): Promise<void> {
return setTheme(nextTheme(theme.value))
}
/**
* Applies the preferences of a freshly loaded profile (boot and sign-in), so
* a second browser of the same admin shows the same theme.
*/
export async function applyProfilePreferences(profile: AdminProfile | null): Promise<void> {
const prefs = (profile as { preferences?: Partial<AdminProfile['preferences']> } | null)?.preferences
if (!prefs) {
return
}
if (isThemeChoice(prefs.theme)) {
if (prefs.theme !== theme.value) {
applyTheme(prefs.theme)
}
cacheTheme(prefs.theme)
}
}
export function usePreferences() {
return {
theme: themeChoice,
setTheme,
cycleTheme,
}
}

View File

@@ -2,9 +2,10 @@
// D-06, D-10). The section panel hides below about 1100px or when the admin
// collapses it. The viewport only forces the collapse and never overwrites
// the admin's own choice, which persists in localStorage as a boolean under
// SIDEBAR_KEY. The only other writer of browser storage is listState.ts,
// which keeps list query parameters per list; tokens and record data are
// never stored (T-10-22, T-16.1-03).
// SIDEBAR_KEY. The other writers of browser storage are listState.ts,
// which keeps list query parameters per list, and usePreferences.ts, which
// caches the theme and locale codes; tokens and record data are never stored
// (T-10-22, T-16.1-03).
import { computed, readonly, ref } from 'vue'
export const SIDEBAR_KEY = 'summer-admin.sidebar'