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:
@@ -17,6 +17,11 @@ const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentEle
|
||||
/** The locale the loaded bundle resolved to (meta.locale of GET /lang). */
|
||||
export const currentLocale = readonly(locale)
|
||||
|
||||
const locales = ref<string[]>([])
|
||||
|
||||
/** The supported admin locales (meta.locales of GET /lang). */
|
||||
export const availableLocales = readonly(locales)
|
||||
|
||||
export function setBundle(next: LangBundle, nextLocale?: string): void {
|
||||
bundle.value = next
|
||||
if (nextLocale) {
|
||||
@@ -27,12 +32,17 @@ export function setBundle(next: LangBundle, nextLocale?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the backend::lang bundle for the admin's locale. Public endpoint. */
|
||||
export async function loadStrings(): Promise<void> {
|
||||
const { data, response } = await api.GET('/lang')
|
||||
/**
|
||||
* Loads the backend::lang bundle. Public endpoint. preferred (the admin's
|
||||
* chosen language) is sent as ?locale=; the server uses it only when it is
|
||||
* a supported admin locale, else the request locale.
|
||||
*/
|
||||
export async function loadStrings(preferred?: string): Promise<void> {
|
||||
const { data, response } = await api.GET('/lang', preferred ? { params: { query: { locale: preferred } } } : {})
|
||||
if (!response.ok || !data) {
|
||||
return
|
||||
}
|
||||
locales.value = Array.isArray(data.meta.locales) ? [...data.meta.locales] : []
|
||||
setBundle(data.data, data.meta.locale)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,27 @@ import { useRouter } from 'vue-router'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { Check, ChevronDown, LogOut, UserRound } from '@lucide/vue'
|
||||
import { availableLocales, currentLocale, t } from '../../app/i18n'
|
||||
import { currentUser, logout, profileInitials } from '../../state/useAuth'
|
||||
import { localePreference, setLocale } from '../../state/usePreferences'
|
||||
|
||||
// User menu (design: Top header): a 44px button with the avatar image (the
|
||||
// initials when the admin has none), the name above the role from /auth/me
|
||||
// (both hidden at tablet width) and a chevron; the 280px menu (Reka
|
||||
// DropdownMenu, role menu) shows the name and e-mail, a My account item that
|
||||
// opens /account and a Wyloguj item. Logging out always ends on the login
|
||||
// opens /account, a Language radio group (when the server offers more than
|
||||
// one admin locale; choosing one saves it and reloads the page) and a
|
||||
// Wyloguj item. Logging out always ends on the login
|
||||
// screen, even when the logout call fails (T-10-23).
|
||||
const router = useRouter()
|
||||
|
||||
@@ -38,6 +45,30 @@ const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || current
|
||||
|
||||
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
||||
|
||||
/** The checked language: the stored preference, else the bundle locale. */
|
||||
const selectedLocale = computed(() => localePreference.value || currentLocale.value)
|
||||
|
||||
/** A language named in itself (polski, English), else its upper-cased code. */
|
||||
function languageName(code: string): string {
|
||||
try {
|
||||
const name = new Intl.DisplayNames([code], { type: 'language' }).of(code)
|
||||
if (name && name !== code) {
|
||||
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
|
||||
}
|
||||
} catch {
|
||||
// An invalid tag falls through to the code.
|
||||
}
|
||||
return code.toUpperCase()
|
||||
}
|
||||
|
||||
const languages = computed(() => availableLocales.value.map((code) => ({ code, name: languageName(code) })))
|
||||
|
||||
function onLocale(value: unknown): void {
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
void setLocale(value)
|
||||
}
|
||||
}
|
||||
|
||||
async function onAccount(): Promise<void> {
|
||||
await router.push({ name: 'account' }).catch(() => undefined)
|
||||
}
|
||||
@@ -111,6 +142,29 @@ async function onLogout(): Promise<void> {
|
||||
<UserRound :size="18" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.myaccount.menu_label') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<template v-if="languages.length > 1">
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuLabel class="px-3 pt-1 pb-1 text-[12px] font-semibold text-muted" data-language-label>{{
|
||||
t('backend::lang.nav.language')
|
||||
}}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="selectedLocale" @update:model-value="onLocale">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="language in languages"
|
||||
:key="language.code"
|
||||
:value="language.code"
|
||||
:data-locale-option="language.code"
|
||||
:lang="language.code"
|
||||
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
|
||||
>
|
||||
<span class="flex size-[18px] items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Check :size="18" aria-hidden="true" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<span>{{ language.name }}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuItem
|
||||
data-action="logout"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { loadFieldTypes } from './app/fieldTypes'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { applyTheme, cachedTheme } from './state/usePreferences'
|
||||
import { applyTheme, cachedLocale, cachedTheme } from './state/usePreferences'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
|
||||
@@ -18,7 +18,9 @@ async function boot(): Promise<void> {
|
||||
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||
// login screen is translated, then the session, then navigation and the
|
||||
// plugin field types.
|
||||
await loadStrings().catch(() => undefined)
|
||||
// The cached admin language picks the bundle's locale; me() reloads the
|
||||
// strings when the stored preference differs.
|
||||
await loadStrings(cachedLocale() || undefined).catch(() => undefined)
|
||||
// A 401 here only means "not signed in"; the router guard sends the
|
||||
// visitor to the login route with the requested path as redirect.
|
||||
const user = await me().catch(() => null)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ The user menu in the top bar has a My account item that opens `<prefix>/account`
|
||||
|
||||
The button at the top of the plugin rail switches the theme: each click moves from system (follow the browser's `prefers-color-scheme`) to light, to dark and back to system, and its icon and label name the current choice. The choice is saved for the signed-in administrator through `PUT <prefix>/api/v1/account/preferences` and comes back as `preferences.theme` in `GET .../auth/me`, so another browser shows the same theme after sign-in. The SPA also caches the last choice in `localStorage` (`summer-admin.theme`) and applies it before anything renders. When the save fails the previous theme returns with an error message. See [My account](users-and-permissions.md#my-account).
|
||||
|
||||
When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload.
|
||||
|
||||
## Types from OpenAPI
|
||||
|
||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:
|
||||
|
||||
Reference in New Issue
Block a user