feat(i18n): bilingual UI (Russian + English) + language selector
Все UI-строки приложения переведены и переключаются на лету через
Settings → Язык интерфейса.
== i18n архитектура ==
- src/renderer/src/i18n/dict.ts — плоский словарь ru/en с ~190 ключами,
поддержка интерполяции {var} и плюрализации
- src/renderer/src/i18n/index.ts — useT() React hook + чистые
translate/translateN функции (для ReminderApp вне store context)
- Settings.language: 'ru' | 'en', default 'ru'
- Изменение языка применяется немедленно через Zustand reactive update
== Что переведено ==
- Sidebar nav + slogan + status
- Titlebar window controls (aria-labels)
- Dashboard: hero, 3 stat-карточки (Активных / До следующего /
Трекинг матчей), Paused banner, empty state
- Exercises: hero, секции (активные / выключенные), row meta, empty
- Challenges: hero, formula subtitle, warning, row format
«{stat} × {mult} → {exercise}», empty
- Games: hero, status badges (Live/Ready/Queued/Installed/Not found),
queued/no_user banners, dev panel
- Settings: все секции + новый Language selector
- UpdaterCard: все состояния (checking/available/downloading/
downloaded/error/idle) с интерполяцией версии и MB/s
- ReminderApp: kicker «Время тренировки», reps подпись, snooze label
с динамическими минутами, кнопки done/skip
- Match summary: победа/поражение, плюрализация «N челлендж/-а/-ей»
vs «N challenge/-s»
- Format helpers (formatCountdown, formatInterval) — теперь принимают
Language параметр
== Локалезависимая дата ==
Dashboard hero показывает today в текущей локали:
ru-RU → "воскресенье, 17 мая"
en-US → "Sunday, May 17"
== STAT_LABELS bilingual ==
- shared/types.ts: STAT_LABELS_EN + statLabel(stat, lang) helper
- ChallengeResult получил поле stat?: GameStat (для resolve на стороне
renderer'а с актуальным языком, вместо baked-in label)
- main/games/registry.ts кладёт stat в результат
== Тесты ==
- src/renderer/src/i18n/i18n.test.ts: 10 кейсов
* translate: lookup, fallback, interpolation, multi-var, lang fallback
* translateN: ru plural rules (1/21/101 → one; 2-4 → few; 0/5-20 → many)
и en (1 → one, else → many)
- Всего 33 теста зелёные
== Известное ограничение ==
SAMPLE_EXERCISES (5-6 русских "Приседания / Отжимания / ...") остаются
русскими — это seed данных на первый запуск. Английский юзер сразу
переключит язык и сможет переименовать вручную. Делать seed-per-locale
оверкилл — слишком много кода ради малого.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,14 @@ import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
import { useT } from '../i18n'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
const ticks = useAppStore((s) => s.ticks)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||
const { t, lang } = useT()
|
||||
|
||||
const exercises = state?.exercises ?? []
|
||||
const settings = state?.settings
|
||||
@@ -57,70 +59,79 @@ export default function Dashboard(): JSX.Element {
|
||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||
}
|
||||
|
||||
const today = new Date().toLocaleDateString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
const today = new Date().toLocaleDateString(
|
||||
lang === 'en' ? 'en-US' : 'ru-RU',
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||
{/* Hero — iOS Large Title */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
||||
{today}
|
||||
</div>
|
||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||
Сегодня
|
||||
{t('dashboard.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="tinted" onClick={togglePause}>
|
||||
{!paused ? (
|
||||
<>
|
||||
<Pause size={14} strokeWidth={2.5} /> Пауза
|
||||
<Pause size={14} strokeWidth={2.5} /> {t('btn.pause')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={14} strokeWidth={2.5} /> Старт
|
||||
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero stat panel — Apple Fitness style */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
||||
<HeroStat
|
||||
tone="accent"
|
||||
label="Активных"
|
||||
label={t('dashboard.stat.active')}
|
||||
value={`${stats.active}`}
|
||||
subvalue={`из ${stats.total}`}
|
||||
subvalue={t('dashboard.stat.active.of', { total: stats.total })}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone="info"
|
||||
label="До следующего"
|
||||
label={t('dashboard.stat.next')}
|
||||
value={
|
||||
stats.nextMs === Infinity
|
||||
? '—'
|
||||
: stats.nextMs <= 0
|
||||
? 'Сейчас'
|
||||
: formatCountdown(stats.nextMs)
|
||||
? t('dashboard.stat.next.now')
|
||||
: formatCountdown(stats.nextMs, lang)
|
||||
}
|
||||
subvalue={
|
||||
paused
|
||||
? t('dashboard.stat.next.subtitle_paused')
|
||||
: t('dashboard.stat.next.subtitle_running')
|
||||
}
|
||||
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={gamesEnabled ? 'success' : 'muted'}
|
||||
label="Трекинг матчей"
|
||||
value={gamesEnabled ? 'On' : 'Off'}
|
||||
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
|
||||
label={t('dashboard.stat.tracking')}
|
||||
value={
|
||||
gamesEnabled
|
||||
? t('dashboard.stat.tracking.on')
|
||||
: t('dashboard.stat.tracking.off')
|
||||
}
|
||||
subvalue={
|
||||
gamesEnabled
|
||||
? t('dashboard.stat.tracking.subtitle_on')
|
||||
: t('dashboard.stat.tracking.subtitle_off')
|
||||
}
|
||||
icon={
|
||||
<span
|
||||
className={[
|
||||
@@ -132,7 +143,6 @@ export default function Dashboard(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Paused banner */}
|
||||
{paused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
@@ -144,19 +154,18 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[16px] font-semibold leading-tight">
|
||||
Напоминания на паузе
|
||||
{t('dashboard.paused.title')}
|
||||
</div>
|
||||
<div className="text-[14px] text-text/70 mt-1">
|
||||
Возобнови, чтобы продолжить отсчёт
|
||||
{t('dashboard.paused.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="filled" size="sm" onClick={togglePause}>
|
||||
<Play size={14} strokeWidth={2.5} /> Старт
|
||||
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Cards grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
||||
<AnimatePresence>
|
||||
{exercises.map((ex) => (
|
||||
@@ -179,10 +188,10 @@ export default function Dashboard(): JSX.Element {
|
||||
<Plus size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="font-display text-[20px] font-semibold">
|
||||
Программа пуста
|
||||
{t('dashboard.empty.title')}
|
||||
</div>
|
||||
<p className="text-[14px] text-text/55 mt-1">
|
||||
Добавь первое упражнение, чтобы начать
|
||||
{t('dashboard.empty.hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user