diff --git a/client/index.html b/client/index.html index 46d5726..dd4a2be 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - +
diff --git a/client/src/app/i18n.ts b/client/src/app/i18n.ts index 07cd10e..520e18a 100644 --- a/client/src/app/i18n.ts +++ b/client/src/app/i18n.ts @@ -1,22 +1,34 @@ import { createI18n } from 'vue-i18n' import { en } from '../locales/en' import { pl } from '../locales/pl' +import { uk } from '../locales/uk' +import { DEFAULT_LOCALE, loadLocale, saveLocale, type AppLocale } from './locale' -export const SUPPORTED_LOCALES = ['pl', 'en'] as const -export type AppLocale = (typeof SUPPORTED_LOCALES)[number] +const initialLocale = loadLocale() export const i18n = createI18n({ legacy: false, - locale: 'pl', - fallbackLocale: 'en', - messages: { pl, en }, + locale: initialLocale, + fallbackLocale: DEFAULT_LOCALE, + messages: { pl, en, uk }, }) +if (typeof document !== 'undefined') { + document.documentElement.lang = initialLocale +} + export function currentLocale(): AppLocale { return i18n.global.locale.value as AppLocale } export function setLocale(locale: AppLocale): void { i18n.global.locale.value = locale - document.documentElement.lang = locale + + if (typeof document !== 'undefined') { + document.documentElement.lang = locale + } + + saveLocale(locale) } + +export { DEFAULT_LOCALE, SUPPORTED_LOCALES, detectLocale, type AppLocale } from './locale' diff --git a/client/src/app/locale.ts b/client/src/app/locale.ts new file mode 100644 index 0000000..b372417 --- /dev/null +++ b/client/src/app/locale.ts @@ -0,0 +1,50 @@ +export const SUPPORTED_LOCALES = ['pl', 'en', 'uk'] as const +export type AppLocale = (typeof SUPPORTED_LOCALES)[number] + +/** + * Default application language is English. Polish is shown automatically when the + * browser is set to Polish (it is a Polish application); Ukrainian is supported + * as an additional language. + */ +export const DEFAULT_LOCALE: AppLocale = 'en' +const STORAGE_KEY = 'tarninahub-locale' + +function isSupported(value: string): value is AppLocale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value) +} + +export function detectLocale(browserLanguage: string | undefined): AppLocale { + const language = (browserLanguage ?? '').toLowerCase() + + if (language.startsWith('pl')) { + return 'pl' + } + + if (language.startsWith('uk')) { + return 'uk' + } + + return DEFAULT_LOCALE +} + +/** Stored user choice wins over browser detection. */ +export function loadLocale(): AppLocale { + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored && isSupported(stored)) { + return stored + } + } catch { + // localStorage can be unavailable (private mode); fall back to detection. + } + + return detectLocale(typeof navigator === 'undefined' ? undefined : navigator.language) +} + +export function saveLocale(locale: AppLocale): void { + try { + window.localStorage.setItem(STORAGE_KEY, locale) + } catch { + // Ignore persistence failures. + } +} diff --git a/client/src/app/router.ts b/client/src/app/router.ts index 7541921..6d3540d 100644 --- a/client/src/app/router.ts +++ b/client/src/app/router.ts @@ -1,4 +1,4 @@ -import { nextTick } from 'vue' +import { nextTick, watch } from 'vue' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { i18n } from './i18n' @@ -79,9 +79,12 @@ export const router = createRouter({ * SPA accessibility: announce the new document title and move focus to the page * heading after each navigation (WCAG 2.4.2, 2.4.3). */ +function titleForRoute(titleKey: string | undefined): string { + return `${t(titleKey ?? 'app.name')} — ${t('app.name')}` +} + router.afterEach(async (to) => { - const titleKey = (to.meta.titleKey as string | undefined) ?? 'app.name' - document.title = `${t(titleKey)} — ${t('app.name')}` + document.title = titleForRoute(to.meta.titleKey as string | undefined) await nextTick() @@ -92,4 +95,9 @@ router.afterEach(async (to) => { } }) +// Keep the document title in the selected language (WCAG 2.4.2). +watch(i18n.global.locale, () => { + document.title = titleForRoute(router.currentRoute.value.meta.titleKey as string | undefined) +}) + export default router diff --git a/client/src/composables/useLanguage.ts b/client/src/composables/useLanguage.ts new file mode 100644 index 0000000..736a5ab --- /dev/null +++ b/client/src/composables/useLanguage.ts @@ -0,0 +1,32 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { useLocale } from 'vuetify' +import { setLocale, type AppLocale } from '../app/i18n' + +export interface LanguageOption { + value: AppLocale + /** Native name of the language — shown as-is so users find their language. */ + endonym: string + icon: string +} + +export const languageOptions: LanguageOption[] = [ + { value: 'pl', endonym: 'Polski', icon: 'mdi-alpha-p-circle-outline' }, + { value: 'en', endonym: 'English', icon: 'mdi-alpha-e-circle-outline' }, + { value: 'uk', endonym: 'Українська', icon: 'mdi-alpha-u-circle-outline' }, +] + +/** Keeps vue-i18n, Vuetify locale and in sync and persists the choice. */ +export function useLanguage() { + const { locale } = useI18n() + const vuetifyLocale = useLocale() + + const current = computed(() => locale.value as AppLocale) + + function set(value: AppLocale): void { + setLocale(value) + vuetifyLocale.current.value = value + } + + return { current, options: languageOptions, set } +} diff --git a/client/src/layouts/DefaultLayout.vue b/client/src/layouts/DefaultLayout.vue index e34c8bf..f6828c8 100644 --- a/client/src/layouts/DefaultLayout.vue +++ b/client/src/layouts/DefaultLayout.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useA11yPrefs } from '../composables/useA11yPrefs' +import { useLanguage } from '../composables/useLanguage' import { useThemeMode } from '../composables/useThemeMode' interface NavItem { @@ -20,6 +21,12 @@ const activeTheme = computed( const { fontScale, canIncrease, canDecrease, increase, decrease, reset } = useA11yPrefs() +const { current: currentLanguage, options: languageOptions, set: setLanguage } = useLanguage() +const activeLanguage = computed( + () => + languageOptions.find((option) => option.value === currentLanguage.value) ?? languageOptions[1]!, +) + const navItems: NavItem[] = [ { name: 'home', labelKey: 'nav.home', icon: 'mdi-home-outline' }, { name: 'submissions', labelKey: 'nav.submissions', icon: 'mdi-bullhorn-outline' }, @@ -47,6 +54,30 @@ const navItems: NavItem[] = [