// The signed-in admin's UI preferences: the theme (system, light or dark) // and the admin language. The server keeps them per admin (GET /auth/me // preferences, PUT /account/preferences); the last choices are also cached in // localStorage under THEME_KEY and LOCALE_KEY, so boot applies the theme // before the first await (the shell never renders in the wrong theme) and // loads the strings in the chosen language. Only the codes are 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 { currentLocale, loadStrings, 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' export const LOCALE_KEY = 'summer-admin.locale' /** A short locale code: letters with an optional region, e.g. pl or pt-BR. */ const LOCALE_SHAPE = /^[A-Za-z]{2,8}(?:[-_][A-Za-z0-9]{1,8})?$/ const theme = ref('system') let stopTheme: () => void = () => undefined /** The theme currently applied. */ export const themeChoice = readonly(theme) const localePref = ref('') /** The admin language the server stores; "" follows the request locale. */ export const localePreference = readonly(localePref) 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' } } /** The cached admin language when it is a locale-shaped code, else "". */ export function cachedLocale(): string { try { const value = storage()?.getItem(LOCALE_KEY) ?? '' return value.length <= 16 && LOCALE_SHAPE.test(value) ? value : '' } catch { return '' } } /** Page reload indirection, so tests can stub it. */ export const page = { reload(): void { window.location.reload() }, } /** 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 { 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 { 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 { const prefs = (profile as { preferences?: Partial } | null)?.preferences if (!prefs) { return } if (isThemeChoice(prefs.theme)) { if (prefs.theme !== theme.value) { applyTheme(prefs.theme) } cacheTheme(prefs.theme) } if (typeof prefs.locale === 'string') { localePref.value = prefs.locale if (prefs.locale !== '' && prefs.locale !== currentLocale.value) { await loadStrings(prefs.locale).catch(() => undefined) } cache(LOCALE_KEY, prefs.locale === '' ? null : prefs.locale) } } /** * Saves the admin language and reloads the page, so strings, navigation, * settings, schemas and field types all come back in it at once (a form * with unsaved changes asks first through its beforeunload prompt). The * current preference does nothing; a failed save shows a danger toast. */ export async function setLocale(code: string): Promise { if (code === localePref.value) { return } let ok = false try { const { response } = await api.PUT('/account/preferences', { body: { locale: code } }) ok = response.ok } catch { ok = false } if (!ok) { showToast(t('backend::lang.myaccount.preferences_failed'), 'danger') return } localePref.value = code cache(LOCALE_KEY, code === '' ? null : code) page.reload() } export function usePreferences() { return { theme: themeChoice, locale: localePreference, setTheme, cycleTheme, setLocale, } }