This commit is contained in:
JeffyOLOLO
2026-04-27 11:26:14 +00:00
committed by GitHub
10 changed files with 2437 additions and 7 deletions
+2 -1
View File
@@ -14,9 +14,10 @@ import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
import uk from '../i18n/translations/uk'
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar, uk,
}
function translateRateLimit(): string {
@@ -246,6 +246,38 @@ const texts: Record<string, DemoTexts> = {
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
uk: {
titleBefore: 'Ласкаво просимо до ',
titleAfter: '',
title: 'Ласкаво просимо до демо-версії TREK',
description: 'Ви можете переглядати, редагувати та створювати поїздки. Всі зміни автоматично скидаються кожну годину.',
resetIn: 'Наступне скидання через',
minutes: 'хвилин(и)',
uploadNote: 'Завантаження файлів (фото, документи, обкладинки) відключено в демонстраційному режимі.',
fullVersionTitle: 'Додатково в повній версії:',
features: [
'Завантаження файлів (фото, документи, обкладинки)',
'Керування API ключами (Google Maps, погода)',
'Керування користувачами та правами доступу',
'Автоматичні резервні копії',
'Керування аддонами (включити/виключити)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Модульні Аддони (можна виключити в повній версії)',
addons: [
['Vacay', 'Планувальник відпусток з календарем, святами та підтримкою кількох користувачів'],
['Atlas', 'Карта світу з відвіданими країнами та статистикою подорожей'],
['Packing', 'Списки для кожної поїздки'],
['Budget', 'Відстеження витрат для кожної особи'],
['Documents', 'Прикріплення файлів до поїздок'],
['Widgets', 'Конвертер валют та часові пояси'],
],
whatIs: 'Що таке TREK?',
whatIsDesc: 'Планувальник подорожей з підтримкою реального часу, інтерактивними картами, входом через OIDC та темною темою.',
selfHost: 'Відкритий код — ',
selfHostLink: 'за\'self-host\'ь це',
close: 'Зрозуміло',
},
}
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
+3 -2
View File
@@ -15,6 +15,7 @@ import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
import uk from './translations/uk'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES }
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, uk,
}
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'uk'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
+1
View File
@@ -14,6 +14,7 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
{ value: 'uk', label: 'Українська', locale: 'uk-UA' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -47,6 +47,7 @@ describe('getLocaleForLanguage', () => {
expect(getLocaleForLanguage('zh-TW')).toBe('zh-TW')
expect(getLocaleForLanguage('ar')).toBe('ar-SA')
expect(getLocaleForLanguage('br')).toBe('pt-BR')
expect(getLocaleForLanguage('uk')).toBe('uk-UA')
})
it('FE-COMP-I18N-003: falls back to en-US for unknown language codes', () => {
@@ -61,6 +62,7 @@ describe('getIntlLanguage', () => {
expect(getIntlLanguage('de')).toBe('de')
expect(getIntlLanguage('fr')).toBe('fr')
expect(getIntlLanguage('zh-TW')).toBe('zh-TW')
expect(getIntlLanguage('uk')).toBe('uk')
})
it('FE-COMP-I18N-005: maps br to pt-BR', () => {
@@ -83,6 +85,7 @@ describe('isRtlLanguage', () => {
expect(isRtlLanguage('en')).toBe(false)
expect(isRtlLanguage('de')).toBe(false)
expect(isRtlLanguage('zh-TW')).toBe(false)
expect(isRtlLanguage('uk')).toBe(false)
})
})
@@ -91,9 +94,10 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
expect(SUPPORTED_LANGUAGES).toHaveLength(16)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
})
})
+1 -1
View File
@@ -104,7 +104,7 @@ export const ENCRYPTION_KEY = _encryptionKey;
// Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
// Kept duplicated here because server and client are separate npm packages.
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar', 'uk'];
const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
+14
View File
@@ -89,6 +89,7 @@ const I18N: Record<string, EmailStrings> = {
'zh-TW': { footer: '您收到這封郵件是因為您在 TREK 中啟用了通知。', manage: '管理偏好設定', madeWith: 'Made with', openTrek: '開啟 TREK' },
ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
id: { footer: 'Anda menerima ini karena Anda telah mengaktifkan notifikasi di TREK.', manage: 'Kelola preferensi di Pengaturan', madeWith: 'Dibuat dengan', openTrek: 'Buka TREK' },
uk: { footer: 'Ви отримали це, тому що у вас увімкнені сповіщення в TREK.', manage: 'Керувати налаштуваннями', madeWith: 'Зроблено з', openTrek: 'Відкрити TREK' },
};
// Translated notification texts per event type
@@ -275,6 +276,18 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Pengepakan: ${p.category}`, body: `${p.actor} menugaskan Anda ke kategori "${p.category}" di "${p.trip}".` }),
version_available: p => ({ title: 'Versi TREK baru tersedia', body: `TREK ${p.version} sekarang tersedia. Kunjungi panel admin untuk memperbarui.` }),
},
uk: {
trip_invite: p => ({ title: `Запрошення до "${p.trip}"`, body: `${p.actor} запросив ${p.invitee || 'учасника'} до поїздки "${p.trip}".` }),
booking_change: p => ({ title: `Нове бронювання: ${p.booking}`, body: `${p.actor} додав бронювання "${p.booking}" (${p.type}) до "${p.trip}".` }),
trip_reminder: p => ({ title: `Нагадування про поїздку: ${p.trip}`, body: `Ваша поїздка "${p.trip}" скоро почнеться!` }),
todo_due: p => ({ title: `Завдання до виконання: ${p.todo}`, body: `"${p.todo}" в "${p.trip}" має бути виконано до ${p.due}.` }),
vacay_invite: p => ({ title: 'Запрошення Vacay Fusion', body: `${p.actor} запрошує вас об’єднати плани відпустки. Відкрийте TREK, щоб прийняти або відхилити.` }),
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поділився ${p.count} фото в "${p.trip}".` }),
collab_message: p => ({ title: `Нове повідомлення в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Список речей: ${p.category}`, body: `${p.actor} назначив вас до категорії "${p.category}" в "${p.trip}".` }),
version_available: p => ({ title: 'Доступна нова версія TREK', body: `TREK ${p.version} тепер доступний. Перейдіть в панель адміністратора для оновлення.` }),
synology_session_cleared: () => ({ title: 'Сесія Synology очищена', body: 'Ваш обліковий запис або URL-адреса Synology змінилися. Ви вийшли з Synology Photos.' }),
},
};
// Get localized event text
@@ -350,6 +363,7 @@ const PASSWORD_RESET_I18N: Record<string, PasswordResetStrings> = {
br: { subject: 'Redefinir sua senha', greeting: 'Olá', body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.', ctaIntro: 'Redefinir senha', expiry: 'Este link expira em 60 minutos.', ignore: 'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.' },
cs: { subject: 'Obnovení hesla', greeting: 'Ahoj', body: 'Obdrželi jsme žádost o obnovení hesla k tvému účtu TREK. Klikni na tlačítko níže a nastav nové heslo.', ctaIntro: 'Obnovit heslo', expiry: 'Odkaz vyprší za 60 minut.', ignore: 'Pokud jsi o obnovení nežádal/a, tento e-mail ignoruj — heslo zůstane beze změny.' },
pl: { subject: 'Zresetuj hasło', greeting: 'Cześć', body: 'Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta TREK. Kliknij przycisk poniżej, aby ustawić nowe hasło.', ctaIntro: 'Zresetuj hasło', expiry: 'Link wygaśnie za 60 minut.', ignore: 'Jeśli to nie Ty, zignoruj tę wiadomość — Twoje hasło pozostanie bez zmian.' },
uk: { subject: 'Скидання пароля', greeting: 'Привіт', body: 'Ми отримали запит на скидання пароля для вашого облікового запису TREK. Натисніть кнопку нижче, щоб встановити новий пароль.', ctaIntro: 'Скинути пароль', expiry: 'Це посилання дійсне протягом 60 хвилин.', ignore: 'Якщо ви не запитували скидання — просто проігноруйте цей лист, пароль залишиться без змін.' },
};
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
+1 -1
View File
@@ -60,4 +60,4 @@ See the [[Development Environment|Development-environment]] page for the full se
| Database | SQLite with WAL mode |
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
| i18n | 15 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, ZH-TW, AR, ID) |
| i18n | 16 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, ZH-TW, AR, ID, UK) |
+1 -1
View File
@@ -40,7 +40,7 @@ Setting `ENCRYPTION_KEY` explicitly is recommended so you can back it up indepen
Verified in `server/src/config.ts` (line 107):
`de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`
`de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `uk`
> **Note:** `id` (Indonesian / Bahasa Indonesia) appears in `client/src/i18n/supportedLanguages.ts` but is not in the server's supported-codes list in `config.ts`. Setting `DEFAULT_LANGUAGE=id` will fall back to `en` with a warning in the server log.