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:
Rafał Tyński
2026-10-04 04:12:36 +02:00
parent 9854133ac0
commit e176d0c211
11 changed files with 258 additions and 14 deletions
+1 -1
View File
@@ -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" />
+17 -5
View File
@@ -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
if (typeof document !== 'undefined') {
document.documentElement.lang = locale
}
saveLocale(locale)
}
export { DEFAULT_LOCALE, SUPPORTED_LOCALES, detectLocale, type AppLocale } from './locale'
+50
View File
@@ -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.
}
}
+11 -3
View File
@@ -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
+32
View File
@@ -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 }
}
+31
View File
@@ -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
+4
View File
@@ -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',
+6 -2
View File
@@ -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)
})
})
+4
View File
@@ -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',
+98
View File
@@ -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 -2
View File
@@ -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 },