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>
|
<!doctype html>
|
||||||
<html lang="pl">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|||||||
+18
-6
@@ -1,22 +1,34 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { en } from '../locales/en'
|
import { en } from '../locales/en'
|
||||||
import { pl } from '../locales/pl'
|
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
|
const initialLocale = loadLocale()
|
||||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'pl',
|
locale: initialLocale,
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: DEFAULT_LOCALE,
|
||||||
messages: { pl, en },
|
messages: { pl, en, uk },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = initialLocale
|
||||||
|
}
|
||||||
|
|
||||||
export function currentLocale(): AppLocale {
|
export function currentLocale(): AppLocale {
|
||||||
return i18n.global.locale.value as AppLocale
|
return i18n.global.locale.value as AppLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLocale(locale: AppLocale): void {
|
export function setLocale(locale: AppLocale): void {
|
||||||
i18n.global.locale.value = locale
|
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 { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { i18n } from './i18n'
|
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
|
* SPA accessibility: announce the new document title and move focus to the page
|
||||||
* heading after each navigation (WCAG 2.4.2, 2.4.3).
|
* 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) => {
|
router.afterEach(async (to) => {
|
||||||
const titleKey = (to.meta.titleKey as string | undefined) ?? 'app.name'
|
document.title = titleForRoute(to.meta.titleKey as string | undefined)
|
||||||
document.title = `${t(titleKey)} — ${t('app.name')}`
|
|
||||||
|
|
||||||
await nextTick()
|
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
|
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 { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useA11yPrefs } from '../composables/useA11yPrefs'
|
import { useA11yPrefs } from '../composables/useA11yPrefs'
|
||||||
|
import { useLanguage } from '../composables/useLanguage'
|
||||||
import { useThemeMode } from '../composables/useThemeMode'
|
import { useThemeMode } from '../composables/useThemeMode'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -20,6 +21,12 @@ const activeTheme = computed(
|
|||||||
|
|
||||||
const { fontScale, canIncrease, canDecrease, increase, decrease, reset } = useA11yPrefs()
|
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[] = [
|
const navItems: NavItem[] = [
|
||||||
{ name: 'home', labelKey: 'nav.home', icon: 'mdi-home-outline' },
|
{ name: 'home', labelKey: 'nav.home', icon: 'mdi-home-outline' },
|
||||||
{ name: 'submissions', labelKey: 'nav.submissions', icon: 'mdi-bullhorn-outline' },
|
{ name: 'submissions', labelKey: 'nav.submissions', icon: 'mdi-bullhorn-outline' },
|
||||||
@@ -47,6 +54,30 @@ const navItems: NavItem[] = [
|
|||||||
</v-app-bar-title>
|
</v-app-bar-title>
|
||||||
<v-spacer />
|
<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">
|
<v-menu location="bottom end">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const en: Messages = {
|
|||||||
logout: 'Sign out',
|
logout: 'Sign out',
|
||||||
openMenu: 'Open main menu',
|
openMenu: 'Open main menu',
|
||||||
},
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Language',
|
||||||
|
change: 'Change language',
|
||||||
|
},
|
||||||
theme: {
|
theme: {
|
||||||
label: 'Theme',
|
label: 'Theme',
|
||||||
change: 'Change theme',
|
change: 'Change theme',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { en } from './en'
|
import { en } from './en'
|
||||||
import { pl } from './pl'
|
import { pl } from './pl'
|
||||||
|
import { uk } from './uk'
|
||||||
|
|
||||||
function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
|
function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
|
||||||
return Object.entries(source).flatMap(([key, value]) => {
|
return Object.entries(source).flatMap(([key, value]) => {
|
||||||
@@ -12,7 +13,10 @@ function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('i18n messages', () => {
|
describe('i18n messages', () => {
|
||||||
it('defines the same keys in Polish and English', () => {
|
it('defines the same keys in every supported language', () => {
|
||||||
expect(flatKeys(en).sort()).toEqual(flatKeys(pl).sort())
|
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ę',
|
logout: 'Wyloguj się',
|
||||||
openMenu: 'Otwórz menu główne',
|
openMenu: 'Otwórz menu główne',
|
||||||
},
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Język',
|
||||||
|
change: 'Zmień język',
|
||||||
|
},
|
||||||
theme: {
|
theme: {
|
||||||
label: 'Motyw',
|
label: 'Motyw',
|
||||||
change: 'Zmień 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 { createVuetify, type ThemeDefinition } from 'vuetify'
|
||||||
import * as components from 'vuetify/components'
|
import * as components from 'vuetify/components'
|
||||||
import * as directives from 'vuetify/directives'
|
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 = {
|
export const light: ThemeDefinition = {
|
||||||
dark: false,
|
dark: false,
|
||||||
@@ -125,7 +126,7 @@ export const highContrast: ThemeDefinition = {
|
|||||||
export default createVuetify({
|
export default createVuetify({
|
||||||
components,
|
components,
|
||||||
directives,
|
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 } },
|
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
|
||||||
defaults: {
|
defaults: {
|
||||||
global: { ripple: false },
|
global: { ripple: false },
|
||||||
|
|||||||
Reference in New Issue
Block a user