feat(quick-261016-0fm): language switcher in the user menu

- 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
This commit is contained in:
Jakub Zych
2026-10-16 11:35:08 +02:00
parent f67e8915b8
commit 5244a37b00
5 changed files with 145 additions and 15 deletions

View File

@@ -1,17 +1,22 @@
// 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).
// 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 { t } from '../app/i18n'
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
@@ -19,6 +24,11 @@ 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
@@ -54,6 +64,23 @@ export function cachedTheme(): ThemeChoice {
}
}
/** 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()
@@ -113,12 +140,47 @@ export async function applyProfilePreferences(profile: AdminProfile | null): Pro
}
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,
}
}