- loadStrings sends the chosen language as ?locale= and keeps meta.locales as availableLocales - the user menu lists the admin locales, each named in its own language; choosing one saves it through PUT /account/preferences, caches it and reloads the page - boot loads the strings in the cached language; me() reloads them when the stored preference differs
187 lines
5.7 KiB
TypeScript
187 lines
5.7 KiB
TypeScript
// 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<ThemeChoice>('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<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)
|
|
}
|
|
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<void> {
|
|
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,
|
|
}
|
|
}
|