From 5244a37b000b24ad6cd2905246370336fa4e0f5f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 16 Oct 2026 11:35:08 +0200 Subject: [PATCH] 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 --- admin/src/app/i18n.ts | 16 +++++- admin/src/components/shell/UserMenu.vue | 60 ++++++++++++++++++- admin/src/main.ts | 6 +- admin/src/state/usePreferences.ts | 76 ++++++++++++++++++++++--- docs/backend/admin-spa.md | 2 + 5 files changed, 145 insertions(+), 15 deletions(-) diff --git a/admin/src/app/i18n.ts b/admin/src/app/i18n.ts index b8a9b8b..5bdbcbb 100644 --- a/admin/src/app/i18n.ts +++ b/admin/src/app/i18n.ts @@ -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([]) + +/** 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 { - 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 { + 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) } diff --git a/admin/src/components/shell/UserMenu.vue b/admin/src/components/shell/UserMenu.vue index 630bbb2..750ecde 100644 --- a/admin/src/components/shell/UserMenu.vue +++ b/admin/src/components/shell/UserMenu.vue @@ -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 { await router.push({ name: 'account' }).catch(() => undefined) } @@ -111,6 +142,29 @@ async function onLogout(): Promise {