feat(client): add English default and Ukrainian language with switcher
Default language is English; detect the browser language (Polish -> pl, Ukrainian -> uk, otherwise en) and persist the user choice. Add full Ukrainian (Cyrillic) translations, a language switcher showing native endonyms, and keep vue-i18n, Vuetify locale, <html lang> and document.title in sync.
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
+18
-6
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <html lang> 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 }
|
||||
}
|
||||
@@ -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[] = [
|
||||
</v-app-bar-title>
|
||||
<v-spacer />
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-translate"
|
||||
:aria-label="`${t('language.label')}: ${activeLanguage.endonym}. ${t('language.change')}`"
|
||||
color="on-primary"
|
||||
variant="text"
|
||||
class="me-1"
|
||||
/>
|
||||
</template>
|
||||
<v-list :aria-label="t('language.change')">
|
||||
<v-list-item
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
:prepend-icon="option.icon"
|
||||
:title="option.endonym"
|
||||
:active="currentLanguage === option.value"
|
||||
:lang="option.value"
|
||||
@click="setLanguage(option.value)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
|
||||
@@ -22,6 +22,10 @@ export const en: Messages = {
|
||||
logout: 'Sign out',
|
||||
openMenu: 'Open main menu',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
change: 'Change language',
|
||||
},
|
||||
theme: {
|
||||
label: 'Theme',
|
||||
change: 'Change theme',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { en } from './en'
|
||||
import { pl } from './pl'
|
||||
import { uk } from './uk'
|
||||
|
||||
function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
|
||||
return Object.entries(source).flatMap(([key, value]) => {
|
||||
@@ -12,7 +13,10 @@ function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
|
||||
}
|
||||
|
||||
describe('i18n messages', () => {
|
||||
it('defines the same keys in Polish and English', () => {
|
||||
expect(flatKeys(en).sort()).toEqual(flatKeys(pl).sort())
|
||||
it('defines the same keys in every supported language', () => {
|
||||
const reference = flatKeys(pl).sort()
|
||||
|
||||
expect(flatKeys(en).sort()).toEqual(reference)
|
||||
expect(flatKeys(uk).sort()).toEqual(reference)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,10 @@ export const pl = {
|
||||
logout: 'Wyloguj się',
|
||||
openMenu: 'Otwórz menu główne',
|
||||
},
|
||||
language: {
|
||||
label: 'Język',
|
||||
change: 'Zmień język',
|
||||
},
|
||||
theme: {
|
||||
label: 'Motyw',
|
||||
change: 'Zmień motyw',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Messages } from './pl'
|
||||
|
||||
export const uk: Messages = {
|
||||
app: {
|
||||
name: 'TarninaHub',
|
||||
tagline: 'Малопольський хаб соціальних інновацій',
|
||||
skipLink: 'Перейти до основного вмісту',
|
||||
footer: 'TarninaHub — Малопольський хаб соціальних інновацій · Хакатон-проєкт',
|
||||
},
|
||||
nav: {
|
||||
label: 'Головне меню',
|
||||
home: 'Головна',
|
||||
submissions: 'Повідомити про потребу',
|
||||
matchmaking: 'Підібрати рішення',
|
||||
knowledge: 'База знань',
|
||||
ideas: 'Конструктор ідей',
|
||||
tester: 'Тестувальник інновацій',
|
||||
communication: 'Спілкування',
|
||||
partners: 'Партнерства',
|
||||
panel: 'Панель',
|
||||
login: 'Увійти',
|
||||
logout: 'Вийти',
|
||||
openMenu: 'Відкрити головне меню',
|
||||
},
|
||||
language: {
|
||||
label: 'Мова',
|
||||
change: 'Змінити мову',
|
||||
},
|
||||
theme: {
|
||||
label: 'Тема',
|
||||
change: 'Змінити тему',
|
||||
light: 'Світла',
|
||||
dark: 'Темна',
|
||||
highContrast: 'Високий контраст',
|
||||
system: 'Системна',
|
||||
},
|
||||
a11y: {
|
||||
prefsLabel: 'Налаштування доступності',
|
||||
fontScale: 'Розмір тексту',
|
||||
fontIncrease: 'Збільшити текст',
|
||||
fontDecrease: 'Зменшити текст',
|
||||
fontReset: 'Відновити типовий розмір тексту',
|
||||
},
|
||||
common: {
|
||||
loading: 'Завантаження…',
|
||||
close: 'Закрити',
|
||||
retry: 'Спробувати ще раз',
|
||||
backHome: 'Повернутися на головну',
|
||||
errorTitle: 'Сталася помилка',
|
||||
},
|
||||
home: {
|
||||
heroTitle: 'Хороші ідеї не повинні залишатися непоміченими',
|
||||
heroLead:
|
||||
'TarninaHub поєднує потреби мешканців Малопольщі зі знаннями, перевіреними рішеннями та людьми, готовими діяти. Разом ми робимо так, щоб цінні соціальні ініціативи потрапляли туди, де вони найпотрібніші.',
|
||||
ctaHow: 'Подивіться, як це працює',
|
||||
ctaAudience: 'Для кого TarninaHub?',
|
||||
missionTitle: 'Наша місія',
|
||||
missionText:
|
||||
'Ми хочемо, щоб жодна цінна соціальна ініціатива не втрачалася через брак інформації, контактів чи підтримки. Технології мають скорочувати шлях від ідеї до дії та полегшувати співпрацю між мешканцями, установами й громадськими організаціями.',
|
||||
howTitle: 'Як це працює?',
|
||||
steps: {
|
||||
submit: {
|
||||
title: 'Повідомте про потребу',
|
||||
text: 'Опишіть соціальну проблему коротким повідомленням — без формальностей, простою мовою.',
|
||||
},
|
||||
solve: {
|
||||
title: 'Знайдіть рішення',
|
||||
text: 'Перегляньте перевірені ініціативи та знання, підібрані до вашого місця й теми.',
|
||||
},
|
||||
act: {
|
||||
title: 'Об’єднайте зусилля',
|
||||
text: 'Запросіть мешканців, установи та організації й разом реалізуйте проєкт.',
|
||||
},
|
||||
},
|
||||
audienceTitle: 'Для кого?',
|
||||
features: {
|
||||
residents: {
|
||||
title: 'Потреби мешканців',
|
||||
text: 'Мешканці та місцеві спільноти повідомляють про реальні проблеми: доступ до опіки, транспорт, підтримка літніх людей, активізація молоді.',
|
||||
},
|
||||
solutions: {
|
||||
title: 'Перевірені рішення',
|
||||
text: 'База знань і готових ідей, які спрацювали в інших громадах, установах і громадських організаціях.',
|
||||
},
|
||||
people: {
|
||||
title: 'Люди, готові діяти',
|
||||
text: 'Ми поєднуємо волонтерів, експертів, громадські організації та місцеву владу для спільного впровадження цінних ініціатив.',
|
||||
},
|
||||
},
|
||||
},
|
||||
placeholder: {
|
||||
text: 'Цей модуль готується. Поверніться незабаром.',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Сторінку не знайдено',
|
||||
text: 'Такої адреси не існує або її було переміщено.',
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import 'vuetify/styles'
|
||||
import { createVuetify, type ThemeDefinition } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { en, pl } from 'vuetify/locale'
|
||||
import { en, pl, uk } from 'vuetify/locale'
|
||||
import { loadLocale } from '../app/locale'
|
||||
|
||||
export const light: ThemeDefinition = {
|
||||
dark: false,
|
||||
@@ -125,7 +126,7 @@ export const highContrast: ThemeDefinition = {
|
||||
export default createVuetify({
|
||||
components,
|
||||
directives,
|
||||
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
|
||||
locale: { locale: loadLocale(), fallback: 'en', messages: { pl, en, uk } },
|
||||
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
|
||||
defaults: {
|
||||
global: { ripple: false },
|
||||
|
||||
Reference in New Issue
Block a user