1 Commits

Author SHA1 Message Date
AnRil
973339ca62 feat(i18n): bilingual UI (Russian + English) + language selector
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
Все 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>
2026-05-17 23:28:34 +07:00
19 changed files with 999 additions and 256 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "laude",
"version": "0.3.7",
"version": "0.4.0",
"description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js",
"author": "AnRil",

View File

@@ -38,7 +38,8 @@ async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<voi
exerciseName: ch.exerciseName,
reps,
statValue,
statLabel: STAT_LABELS[ch.stat]
statLabel: STAT_LABELS[ch.stat],
stat: ch.stat
})
}
if (results.length === 0) return

View File

@@ -22,10 +22,7 @@ export default function App(): JSX.Element {
return (
<HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar
title="Exercise Reminder"
onMenuClick={() => setMobileNavOpen(true)}
/>
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
<div className="flex-1 flex overflow-hidden">
<Sidebar
mobileOpen={mobileNavOpen}

View File

@@ -5,10 +5,13 @@ import type {
Exercise,
MatchSummary,
Settings,
ChallengeResult
ChallengeResult,
Language
} from '@shared/types'
import { statLabel } from '@shared/types'
import { Icon } from './lib/icon'
import { formatInterval } from './lib/format'
import { translate, translateN } from './i18n'
type Mode =
| { kind: 'idle' }
@@ -42,7 +45,6 @@ export default function ReminderApp(): JSX.Element {
}
}, [])
// Keyboard shortcuts (iOS-like Enter to confirm)
useEffect(() => {
if (mode.kind !== 'exercise') return
const ex = mode.exercise
@@ -67,12 +69,15 @@ export default function ReminderApp(): JSX.Element {
window.api.reminderClose()
}
const lang: Language = settings?.language ?? 'ru'
if (mode.kind === 'idle') return <div className="reminder-shell" />
if (mode.kind === 'exercise') {
return (
<ExerciseReminder
exercise={mode.exercise}
snoozeMinutes={settings?.snoozeMinutes ?? 5}
lang={lang}
onClose={close}
/>
)
@@ -81,6 +86,7 @@ export default function ReminderApp(): JSX.Element {
<MatchSummaryView
summary={mode.summary}
done={mode.done}
lang={lang}
onMarkDone={(id) =>
setMode({
kind: 'match',
@@ -96,12 +102,17 @@ export default function ReminderApp(): JSX.Element {
function ExerciseReminder({
exercise,
snoozeMinutes,
lang,
onClose
}: {
exercise: Exercise
snoozeMinutes: number
lang: Language
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record<string, string | number>): string =>
translate(lang, key, vars)
async function done(): Promise<void> {
await window.api.markDone(exercise.id)
onClose()
@@ -121,7 +132,7 @@ function ExerciseReminder({
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={13} strokeWidth={2.5} />
</button>
@@ -140,7 +151,7 @@ function ExerciseReminder({
</motion.div>
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
Время тренировки
{t('reminder.kicker')}
</div>
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
{exercise.name}
@@ -150,35 +161,39 @@ function ExerciseReminder({
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
{exercise.reps}
</span>
<span className="text-[15px] text-text/65 font-semibold">раз</span>
<span className="text-[15px] text-text/65 font-semibold">
{t('reminder.reps')}
</span>
</div>
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
<Clock size={12} strokeWidth={2.4} />
Следующее через {formatInterval(exercise.intervalMinutes)}
{t('reminder.next_in', {
interval: formatInterval(exercise.intervalMinutes, lang)
})}
</div>
</div>
{/* iOS action sheet — buttons stacked vertically, equal width */}
<div className="px-4 pb-4 space-y-2">
<button
onClick={done}
className="w-full h-12 rounded-2xl bg-accent text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={17} strokeWidth={2.5} /> Готово
<Check size={17} strokeWidth={2.5} /> {t('reminder.btn.done')}
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={snooze}
className="h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Clock size={15} strokeWidth={2.5} /> {snoozeMinutes} мин
<Clock size={15} strokeWidth={2.5} />{' '}
{t('btn.snooze_min', { n: snoozeMinutes })}
</button>
<button
onClick={skip}
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
Пропустить
{t('btn.skip')}
</button>
</div>
</div>
@@ -189,14 +204,24 @@ function ExerciseReminder({
function MatchSummaryView({
summary,
done,
lang,
onMarkDone,
onClose
}: {
summary: MatchSummary
done: Set<string>
lang: Language
onMarkDone: (id: string) => void
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record<string, string | number>): string =>
translate(lang, key, vars)
const tn = (
base: string,
n: number,
vars?: Record<string, string | number>
): string => translateN(lang, base, n, vars)
const allDone = summary.results.every((r) => done.has(r.challengeId))
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
const remainingReps = summary.results
@@ -204,6 +229,7 @@ function MatchSummaryView({
.reduce((s, r) => s + r.reps, 0)
const won = summary.won === true
const lost = summary.won === false
const minutes = Math.floor(summary.durationMs / 60_000)
return (
<div className="reminder-shell flex flex-col h-full">
@@ -214,7 +240,7 @@ function MatchSummaryView({
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={13} strokeWidth={2.5} />
</button>
@@ -239,19 +265,23 @@ function MatchSummaryView({
)}
</motion.div>
<h1 className="font-serif text-[26px] tracking-tight font-bold">
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
{won
? t('match.title.won')
: lost
? t('match.title.lost')
: t('match.title.draw')}
</h1>
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
<span className="font-mono-num font-bold text-text">
{Math.floor(summary.durationMs / 60_000)}
</span>{' '}
мин · {summary.results.length} челлендж
{summary.results.length === 1 ? '' : 'а'} ·{' '}
<span className="font-mono-num font-bold text-text">{minutes}</span>{' '}
{t('fmt.m')} ·{' '}
{tn('match.summary.challenges', summary.results.length)}{' · '}
{allDone ? (
<span className="text-success font-bold">всё готово</span>
<span className="text-success font-bold">
{t('match.summary.all_done')}
</span>
) : (
<span className="text-accent font-mono-num font-bold">
{remainingReps} осталось
{t('match.summary.remaining', { n: remainingReps })}
</span>
)}
</p>
@@ -262,6 +292,7 @@ function MatchSummaryView({
<ChallengeRow
key={r.challengeId}
result={r}
lang={lang}
done={done.has(r.challengeId)}
onMarkDone={() => onMarkDone(r.challengeId)}
/>
@@ -270,11 +301,11 @@ function MatchSummaryView({
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
<div className="flex-1 text-[13px] text-text/65 font-medium">
Всего ·{' '}
{t('match.total')} ·{' '}
<span className="text-text font-mono-num font-bold text-[16px]">
{totalReps}
</span>{' '}
повторов
{t('match.total_reps_suffix')}
</div>
<button
onClick={onClose}
@@ -285,10 +316,10 @@ function MatchSummaryView({
>
{allDone ? (
<>
<Check size={14} strokeWidth={2.5} /> Закрыть
<Check size={14} strokeWidth={2.5} /> {t('btn.close')}
</>
) : (
'Позже'
t('btn.later')
)}
</button>
</div>
@@ -298,13 +329,16 @@ function MatchSummaryView({
function ChallengeRow({
result,
lang,
done,
onMarkDone
}: {
result: ChallengeResult
lang: Language
done: boolean
onMarkDone: () => void
}): JSX.Element {
const label = result.stat ? statLabel(result.stat, lang) : result.statLabel
return (
<motion.div
layout
@@ -336,7 +370,7 @@ function ChallengeRow({
<span className="font-mono-num font-bold text-text">
{result.statValue}
</span>{' '}
{result.statLabel} <span>{result.name}</span>
{label} <span>{result.name}</span>
</div>
</div>
<div
@@ -356,7 +390,6 @@ function ChallengeRow({
? 'bg-success text-white cursor-default'
: 'bg-accent text-white active:scale-90'
].join(' ')}
aria-label="Готово"
>
<Check size={15} strokeWidth={2.5} />
</button>

View File

@@ -5,6 +5,7 @@ import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
type Props = {
exercise: Exercise
@@ -34,6 +35,7 @@ export function ExerciseCard({
const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled
const [menuOpen, setMenuOpen] = useState(false)
const { t, lang } = useT()
// Ring math
const R = 22
@@ -104,7 +106,7 @@ export function ExerciseCard({
<button
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label="Меню"
aria-label={t('titlebar.menu_aria')}
>
<MoreHorizontal size={16} />
</button>
@@ -122,7 +124,7 @@ export function ExerciseCard({
}}
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
>
Редактировать
{t('btn.edit')}
</button>
<button
onClick={() => {
@@ -131,7 +133,7 @@ export function ExerciseCard({
}}
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
>
Удалить
{t('btn.delete')}
</button>
</div>
</>
@@ -139,14 +141,17 @@ export function ExerciseCard({
</div>
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
{t('editor.exercise.preview.meta', {
reps: exercise.reps,
min: exercise.intervalMinutes
})}
</div>
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
{isDue ? 'Сейчас' : 'Через'}
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
</div>
<div
className={[
@@ -154,19 +159,20 @@ export function ExerciseCard({
isDue ? 'text-accent' : 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
{exercise.enabled
? formatCountdown(ms, lang)
: t('fmt.paused')}
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label="Включить/выключить"
aria-label={t('btn.done')}
/>
</div>
</div>
</div>
{/* Done action — appears as filled pill at bottom only on due */}
{isDue && (
<motion.button
initial={{ opacity: 0, y: 4 }}
@@ -174,7 +180,7 @@ export function ExerciseCard({
onClick={onMarkDone}
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={15} strokeWidth={2.5} /> Готово
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}
</motion.button>
)}
</motion.div>

View File

@@ -3,6 +3,7 @@ import type { Exercise } from '@shared/types'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon'
import { useT } from '../i18n'
type Draft = {
name: string
@@ -34,6 +35,7 @@ export function ExerciseEditor({
onSave
}: Props): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY)
const { t } = useT()
useEffect(() => {
if (exercise) {
@@ -55,46 +57,52 @@ export function ExerciseEditor({
<Modal
open={open}
onClose={onClose}
title={exercise ? 'Редактировать' : 'Новое упражнение'}
title={
exercise
? t('editor.exercise.title.edit')
: t('editor.exercise.title.new')
}
footer={
<>
<Button variant="plain" onClick={onClose}>
Отмена
{t('btn.cancel')}
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
{t('btn.save')}
</Button>
</>
}
>
<div className="space-y-5">
{/* Live preview header */}
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
</div>
<div className="min-w-0">
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
{draft.name || 'Без названия'}
{draft.name || t('editor.exercise.preview.placeholder')}
</div>
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
{draft.reps} раз · каждые {draft.intervalMinutes} мин
{t('editor.exercise.preview.meta', {
reps: draft.reps,
min: draft.intervalMinutes
})}
</div>
</div>
</div>
<Field label="Название">
<Field label={t('editor.field.name')}>
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Приседания"
placeholder={t('editor.field.name.placeholder')}
className="ios-input"
autoFocus
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Повторений">
<Field label={t('editor.field.reps')}>
<input
type="number"
min={1}
@@ -108,7 +116,7 @@ export function ExerciseEditor({
className="ios-input font-mono-num"
/>
</Field>
<Field label="Интервал (мин)">
<Field label={t('editor.field.interval_min')}>
<input
type="number"
min={1}
@@ -124,7 +132,7 @@ export function ExerciseEditor({
</Field>
</div>
<Field label="Иконка">
<Field label={t('editor.field.icon')}>
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
{ICON_CHOICES.map((name) => (
<button

View File

@@ -8,29 +8,34 @@ import {
Settings2,
X
} from 'lucide-react'
import { useT } from '../i18n'
type Item = {
to: string
label: string
labelKey: string
icon: typeof Sun
end?: boolean
tint?: string
}
// Tinted icon plaques á la iOS Settings rows.
const items: Item[] = [
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
{ to: '/', labelKey: 'nav.today', icon: Sun, end: true, tint: 'bg-accent' },
{
to: '/exercises',
label: 'Упражнения',
labelKey: 'nav.exercises',
icon: Dumbbell,
tint: 'bg-info'
},
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
{ to: '/games', labelKey: 'nav.games', icon: Joystick, tint: 'bg-accent-2' },
{
to: '/challenges',
labelKey: 'nav.challenges',
icon: Flame,
tint: 'bg-warning'
},
{
to: '/settings',
label: 'Настройки',
labelKey: 'nav.settings',
icon: Settings2,
tint: 'bg-text/70'
}
@@ -45,14 +50,13 @@ export function Sidebar({
mobileOpen = false,
onMobileClose
}: Props): JSX.Element {
const { t } = useT()
return (
<>
{/* Desktop sidebar — macOS vibrancy panel */}
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
<SidebarContent />
</aside>
{/* Mobile drawer */}
<AnimatePresence>
{mobileOpen && (
<motion.div
@@ -79,7 +83,7 @@ export function Sidebar({
<button
onClick={onMobileClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={14} strokeWidth={2.5} />
</button>
@@ -93,21 +97,20 @@ export function Sidebar({
}
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
const { t } = useT()
return (
<>
{/* Brand */}
<div className="px-5 pt-7 pb-6">
<div className="font-serif text-[36px] leading-none tracking-tight font-bold">
Laude
</div>
<div className="text-[13px] text-text/55 mt-2 font-medium">
Двигайся осознанно
{t('sidebar.slogan')}
</div>
</div>
{/* Nav */}
<nav className="px-2.5 flex flex-col gap-1">
{items.map(({ to, label, icon: Icon, end, tint }) => (
{items.map(({ to, labelKey, icon: Icon, end, tint }) => (
<NavLink
key={to}
to={to}
@@ -140,7 +143,7 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
: 'text-text/85 font-medium'
].join(' ')}
>
{label}
{t(labelKey)}
</span>
</>
)}
@@ -148,14 +151,13 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
))}
</nav>
{/* Status footer */}
<div className="mt-auto px-5 pb-5">
<div className="flex items-center gap-2 text-[11px] text-text/45">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Активность отслеживается
{t('sidebar.status_tracking')}
</div>
</div>
</>

View File

@@ -1,46 +1,49 @@
import { Minus, X, Square, Menu } from 'lucide-react'
import { useT } from '../i18n'
type Props = {
title: string
title?: string
onMenuClick?: () => void
}
/**
* macOS-style translucent titlebar. Title centred small, no app icon.
* Window buttons sit right; a left-side hamburger surfaces on mobile only.
*/
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
const { t } = useT()
const effectiveTitle = title ?? t('titlebar.app_title')
return (
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
{/* Left: hamburger only on small */}
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && (
<button
onClick={onMenuClick}
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 transition-colors"
aria-label="Меню"
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 hover:text-text transition-colors"
aria-label={t('titlebar.menu_aria')}
>
<Menu size={15} strokeWidth={2} />
</button>
)}
</div>
{/* Centre title */}
<div className="text-[12px] font-medium text-text/55 truncate px-2">
{title}
{effectiveTitle}
</div>
{/* Right window controls */}
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
<WinBtn
onClick={() => window.api.minimizeMain()}
label={t('titlebar.minimize_aria')}
>
<Minus size={13} strokeWidth={2} />
</WinBtn>
<WinBtn onClick={() => window.api.hideMain()} label="В трей">
<WinBtn
onClick={() => window.api.hideMain()}
label={t('titlebar.tray_aria')}
>
<Square size={11} strokeWidth={2} />
</WinBtn>
<WinBtn
onClick={() => window.api.closeMain()}
label="Закрыть"
label={t('titlebar.close_aria')}
danger
>
<X size={13} strokeWidth={2} />

View File

@@ -10,6 +10,7 @@ import {
import { motion } from 'framer-motion'
import { Button } from './ui/Button'
import { Card } from './ui/Card'
import { useT } from '../i18n'
import type { UpdaterStatus } from '@shared/types'
export function UpdaterCard(): JSX.Element {
@@ -67,13 +68,15 @@ function Body({
onDownload: () => void
onInstall: () => void
}): JSX.Element {
const { t } = useT()
if (status.kind === 'unsupported') {
return (
<Cell
tone="muted"
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Auto-update недоступен"
subtitle={status.reason}
title={t('updater.unsupported')}
subtitle={t('updater.unsupported.reason_dev')}
/>
)
}
@@ -81,8 +84,14 @@ function Body({
return (
<Cell
tone="info"
icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
title="Проверяем обновления…"
icon={
<RefreshCw
size={16}
strokeWidth={2.4}
className="animate-spin"
/>
}
title={t('updater.checking')}
/>
)
}
@@ -91,11 +100,11 @@ function Body({
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title="Последняя версия"
subtitle={`Текущая: v${status.currentVersion}`}
title={t('updater.up_to_date')}
subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })}
action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
</Button>
}
/>
@@ -106,15 +115,15 @@ function Body({
<Cell
tone="accent"
icon={<Sparkles size={16} strokeWidth={2.4} />}
title={`Доступна v${status.version}`}
title={t('updater.available.title', { v: status.version })}
subtitle={
status.releaseDate
? new Date(status.releaseDate).toLocaleString('ru-RU')
? new Date(status.releaseDate).toLocaleString()
: undefined
}
action={
<Button size="sm" onClick={onDownload} disabled={busy}>
<Download size={13} strokeWidth={2.5} /> Скачать
<Download size={13} strokeWidth={2.5} /> {t('btn.download')}
</Button>
}
/>
@@ -131,11 +140,14 @@ function Body({
</div>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
Загружаем обновление
{t('updater.downloading.title')}
</div>
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
{t('updater.downloading.subtitle', {
got: mb(status.transferred),
total: mb(status.total),
speed: (status.bytesPerSecond / 1024 / 1024).toFixed(2)
})}
</div>
</div>
<div className="font-mono-num font-bold text-[18px] text-accent">
@@ -157,11 +169,11 @@ function Body({
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={`Готово · v${status.version}`}
subtitle="Перезапусти для применения"
title={t('updater.downloaded.title', { v: status.version })}
subtitle={t('updater.downloaded.subtitle')}
action={
<Button variant="filled" size="sm" onClick={onInstall}>
Перезапустить
{t('btn.restart')}
</Button>
}
/>
@@ -172,11 +184,11 @@ function Body({
<Cell
tone="destructive"
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Ошибка проверки"
title={t('updater.error.title')}
subtitle={status.message}
action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Повторить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.retry')}
</Button>
}
/>
@@ -186,11 +198,11 @@ function Body({
<Cell
tone="muted"
icon={<PackageCheck size={16} strokeWidth={2.4} />}
title="Проверить обновления"
subtitle="Авто-проверка раз в час"
title={t('updater.idle.title')}
subtitle={t('updater.idle.subtitle')}
action={
<Button size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
</Button>
}
/>

View File

@@ -0,0 +1,414 @@
/**
* Flat string dictionary for ru/en. Keys use dot notation but are just
* strings — no nesting overhead.
*
* Interpolation: `{name}` placeholders are replaced via `useT()` helper.
*
* Pluralization: keys ending in `.one`/`.few`/`.many` (ru) or
* `.one`/`.other` (en) are picked by `tn()` helper based on count.
*/
export type Dict = Record<string, string>
export const ru: Dict = {
// Sidebar / nav
'nav.today': 'Сегодня',
'nav.exercises': 'Упражнения',
'nav.games': 'Игры',
'nav.challenges': 'Челленджи',
'nav.settings': 'Настройки',
'sidebar.slogan': 'Двигайся осознанно',
'sidebar.status_tracking': 'Активность отслеживается',
'titlebar.menu_aria': 'Меню',
'titlebar.minimize_aria': 'Свернуть',
'titlebar.tray_aria': 'В трей',
'titlebar.close_aria': 'Закрыть',
'titlebar.app_title': 'Exercise Reminder',
// Common buttons / actions
'btn.add': 'Добавить',
'btn.new': 'Новый',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.done': 'Готово',
'btn.start': 'Старт',
'btn.pause': 'Пауза',
'btn.refresh': 'Обновить',
'btn.edit': 'Редактировать',
'btn.delete': 'Удалить',
'btn.snooze_min': 'Отложить {n} мин',
'btn.skip': 'Пропустить',
'btn.close': 'Закрыть',
'btn.later': 'Позже',
'btn.connect': 'Подключить',
'btn.disconnect': 'Отключить',
'btn.check': 'Проверить',
'btn.download': 'Скачать',
'btn.restart': 'Перезапустить',
'btn.retry': 'Повторить',
// Dashboard
'dashboard.kicker': 'Тренировка дня',
'dashboard.title': 'Сегодня',
'dashboard.stat.active': 'Активных',
'dashboard.stat.active.of': 'из {total}',
'dashboard.stat.next': 'До следующего',
'dashboard.stat.next.now': 'Сейчас',
'dashboard.stat.next.subtitle_paused': 'на паузе',
'dashboard.stat.next.subtitle_running': 'отсчёт идёт',
'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
// Exercises
'exercises.kicker': 'Программа',
'exercises.title': 'Упражнения',
'exercises.section.active': 'Активные · {n}',
'exercises.section.disabled': 'Выключенные · {n}',
'exercises.row.meta': '{reps} раз · {interval}',
'exercises.empty': 'Программа пуста — добавь первое упражнение',
// Exercise editor
'editor.exercise.title.new': 'Новое упражнение',
'editor.exercise.title.edit': 'Редактировать',
'editor.exercise.preview.placeholder': 'Без названия',
'editor.exercise.preview.meta': '{reps} раз · каждые {min} мин',
'editor.field.name': 'Название',
'editor.field.name.placeholder': 'Приседания',
'editor.field.reps': 'Повторений',
'editor.field.interval_min': 'Интервал (мин)',
'editor.field.icon': 'Иконка',
// Challenges
'challenges.kicker': 'Правила за матч',
'challenges.title': 'Челленджи',
'challenges.subtitle': 'Повторов = {formula}',
'challenges.subtitle.formula': 'статистика × коэффициент',
'challenges.warning.no_games':
'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».',
'challenges.section.all': 'Все · {n}',
'challenges.empty':
'Челленджей пока нет. Привяжи упражнение к статистике матча.',
// Challenge editor
'editor.challenge.title.new': 'Новый челлендж',
'editor.challenge.title.edit': 'Редактировать',
'editor.field.challenge_name': 'Название',
'editor.field.challenge_name.placeholder': 'За смерти — приседания',
'editor.field.game': 'Игра',
'editor.field.stat': 'Статистика',
'editor.field.multiplier': 'Коэффициент',
'editor.field.exercise_name': 'Упражнение',
'editor.field.exercise_name.placeholder': 'Приседания',
'editor.challenge.preview.kicker': 'Превью · 5 событий',
'editor.challenge.preview.fallback': 'повторов',
// Games
'games.kicker': 'Трекинг матчей',
'games.title': 'Игры',
'games.subtitle':
'Подключи игру — челленджи сработают сразу после матча',
'games.subtitle.live': '{n} live',
'games.section.supported': 'Поддерживаемые',
'games.scanning': 'Сканируем установленные игры…',
'games.queued.body':
'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.',
'games.no_user.body':
'В Steam нет залогиненного аккаунта (нет папки userdata). Запусти Steam один раз и нажми «Установить интеграцию».',
'games.not_installed.hint': 'Установи игру в Steam и нажми «Обновить»',
'games.dev.toggle': 'dev · симулировать конец матча',
'games.badge.live': 'Live',
'games.badge.ready': 'Готово',
'games.badge.queued': 'В очереди',
'games.badge.installed': 'Установлена',
'games.badge.not_found': 'Не найдена',
// Settings
'settings.kicker': 'Конфигурация',
'settings.title': 'Настройки',
'settings.section.reminders': 'Напоминания',
'settings.section.window': 'Окно и трей',
'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык',
'settings.section.updates': 'Обновления',
'settings.notification_mode.label': 'Режим уведомления',
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
'settings.notification_mode.modal': 'Окно поверх всех',
'settings.notification_mode.toast': 'Системное уведомление',
'settings.notification_mode.both': 'Окно и уведомление',
'settings.sound.label': 'Звук уведомления',
'settings.sound.hint': 'Короткий сигнал при срабатывании',
'settings.snooze.label': '«Отложить» на',
'settings.snooze.hint': 'Сколько минут добавлять при отложении',
'settings.snooze.1': '1 минута',
'settings.snooze.5': '5 минут',
'settings.snooze.10': '10 минут',
'settings.snooze.15': '15 минут',
'settings.snooze.30': '30 минут',
'settings.tray.label': 'Сворачивать в трей',
'settings.tray.hint': 'При закрытии остаётся работать в фоне',
'settings.autostart.label': 'Запускать с Windows',
'settings.autostart.hint': 'Открывать при входе в систему',
'settings.start_minimized.label': 'Запускать свёрнутым',
'settings.start_minimized.hint': 'При автозапуске открывать сразу в трее',
'settings.theme.label': 'Тема',
'settings.theme.hint': 'Светлая / тёмная / как в системе',
'settings.theme.system': 'Как в системе',
'settings.theme.light': 'Светлая',
'settings.theme.dark': 'Тёмная',
'settings.language.label': 'Язык интерфейса',
'settings.language.hint': 'Применяется сразу',
'settings.language.ru': 'Русский',
'settings.language.en': 'English',
'settings.loading': 'Загрузка…',
// Updater
'updater.unsupported': 'Auto-update недоступен',
'updater.unsupported.reason_dev': 'Auto-update недоступен в dev-режиме',
'updater.checking': 'Проверяем обновления…',
'updater.up_to_date': 'Последняя версия',
'updater.up_to_date.subtitle': 'Текущая: v{v}',
'updater.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
'updater.downloaded.title': 'Готово · v{v}',
'updater.downloaded.subtitle': 'Перезапусти для применения',
'updater.error.title': 'Ошибка проверки',
'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час',
// Reminder window
'reminder.kicker': 'Время тренировки',
'reminder.subkicker': 'Двигайся',
'reminder.reps': 'раз',
'reminder.next_in': 'Следующее через {interval}',
'reminder.btn.done': 'Готово',
'match.title.won': 'Победа',
'match.title.lost': 'Поражение',
'match.title.draw': 'Матч завершён',
'match.summary.minutes_count': '{n} мин',
'match.summary.challenges_one': '{n} челлендж',
'match.summary.challenges_few': '{n} челленджа',
'match.summary.challenges_many': '{n} челленджей',
'match.summary.all_done': 'всё готово',
'match.summary.remaining': '{n} осталось',
'match.total': 'Всего',
'match.total_reps_suffix': 'повторов',
// Format helpers
'fmt.now': 'сейчас',
'fmt.h': 'ч',
'fmt.m': 'мин',
'fmt.h_short': 'ч',
'fmt.m_short': 'м',
'fmt.s_short': 'с',
'fmt.paused': 'на паузе',
'fmt.through': 'Через'
}
export const en: Dict = {
// Sidebar / nav
'nav.today': 'Today',
'nav.exercises': 'Exercises',
'nav.games': 'Games',
'nav.challenges': 'Challenges',
'nav.settings': 'Settings',
'sidebar.slogan': 'Move with intention',
'sidebar.status_tracking': 'Activity tracking is on',
'titlebar.menu_aria': 'Menu',
'titlebar.minimize_aria': 'Minimize',
'titlebar.tray_aria': 'To tray',
'titlebar.close_aria': 'Close',
'titlebar.app_title': 'Exercise Reminder',
// Common buttons
'btn.add': 'Add',
'btn.new': 'New',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.done': 'Done',
'btn.start': 'Start',
'btn.pause': 'Pause',
'btn.refresh': 'Refresh',
'btn.edit': 'Edit',
'btn.delete': 'Delete',
'btn.snooze_min': 'Snooze {n}m',
'btn.skip': 'Skip',
'btn.close': 'Close',
'btn.later': 'Later',
'btn.connect': 'Connect',
'btn.disconnect': 'Disconnect',
'btn.check': 'Check',
'btn.download': 'Download',
'btn.restart': 'Restart',
'btn.retry': 'Retry',
// Dashboard
'dashboard.kicker': 'Daily training',
'dashboard.title': 'Today',
'dashboard.stat.active': 'Active',
'dashboard.stat.active.of': 'of {total}',
'dashboard.stat.next': 'Next in',
'dashboard.stat.next.now': 'Now',
'dashboard.stat.next.subtitle_paused': 'paused',
'dashboard.stat.next.subtitle_running': 'counting down',
'dashboard.stat.tracking': 'Match tracking',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.paused.title': 'Reminders paused',
'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start',
// Exercises
'exercises.kicker': 'Program',
'exercises.title': 'Exercises',
'exercises.section.active': 'Active · {n}',
'exercises.section.disabled': 'Disabled · {n}',
'exercises.row.meta': '{reps} reps · {interval}',
'exercises.empty': 'Program is empty — add your first exercise',
// Exercise editor
'editor.exercise.title.new': 'New exercise',
'editor.exercise.title.edit': 'Edit',
'editor.exercise.preview.placeholder': 'Untitled',
'editor.exercise.preview.meta': '{reps} reps · every {min} min',
'editor.field.name': 'Name',
'editor.field.name.placeholder': 'Squats',
'editor.field.reps': 'Reps',
'editor.field.interval_min': 'Interval (min)',
'editor.field.icon': 'Icon',
// Challenges
'challenges.kicker': 'Per-match rules',
'challenges.title': 'Challenges',
'challenges.subtitle': 'Reps = {formula}',
'challenges.subtitle.formula': 'stat × multiplier',
'challenges.warning.no_games':
'Challenges trigger after a match. Connect a game in the Games tab.',
'challenges.section.all': 'All · {n}',
'challenges.empty':
'No challenges yet. Tie an exercise to a match statistic.',
// Challenge editor
'editor.challenge.title.new': 'New challenge',
'editor.challenge.title.edit': 'Edit',
'editor.field.challenge_name': 'Name',
'editor.field.challenge_name.placeholder': 'Squats per death',
'editor.field.game': 'Game',
'editor.field.stat': 'Statistic',
'editor.field.multiplier': 'Multiplier',
'editor.field.exercise_name': 'Exercise',
'editor.field.exercise_name.placeholder': 'Squats',
'editor.challenge.preview.kicker': 'Preview · 5 events',
'editor.challenge.preview.fallback': 'reps',
// Games
'games.kicker': 'Match tracking',
'games.title': 'Games',
'games.subtitle': 'Connect a game — challenges fire right after the match',
'games.subtitle.live': '{n} live',
'games.section.supported': 'Supported',
'games.scanning': 'Scanning installed games…',
'games.queued.body':
'Steam is running. The {opt} option will be added automatically next time Steam closes.',
'games.no_user.body':
'No logged-in Steam account (no userdata folder). Launch Steam once, then click “Connect”.',
'games.not_installed.hint': 'Install the game in Steam and click Refresh',
'games.dev.toggle': 'dev · simulate match end',
'games.badge.live': 'Live',
'games.badge.ready': 'Ready',
'games.badge.queued': 'Queued',
'games.badge.installed': 'Installed',
'games.badge.not_found': 'Not found',
// Settings
'settings.kicker': 'Configuration',
'settings.title': 'Settings',
'settings.section.reminders': 'Reminders',
'settings.section.window': 'Window & tray',
'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language',
'settings.section.updates': 'Updates',
'settings.notification_mode.label': 'Notification mode',
'settings.notification_mode.hint': 'How a reminder appears',
'settings.notification_mode.modal': 'Window on top',
'settings.notification_mode.toast': 'System notification',
'settings.notification_mode.both': 'Window and notification',
'settings.sound.label': 'Notification sound',
'settings.sound.hint': 'Short beep on trigger',
'settings.snooze.label': '“Snooze” for',
'settings.snooze.hint': 'How many minutes to postpone',
'settings.snooze.1': '1 minute',
'settings.snooze.5': '5 minutes',
'settings.snooze.10': '10 minutes',
'settings.snooze.15': '15 minutes',
'settings.snooze.30': '30 minutes',
'settings.tray.label': 'Minimize to tray',
'settings.tray.hint': 'Keep running in background when closed',
'settings.autostart.label': 'Start with Windows',
'settings.autostart.hint': 'Open at system login',
'settings.start_minimized.label': 'Start minimized',
'settings.start_minimized.hint': 'On autostart open straight to tray',
'settings.theme.label': 'Theme',
'settings.theme.hint': 'Light / dark / follow system',
'settings.theme.system': 'System',
'settings.theme.light': 'Light',
'settings.theme.dark': 'Dark',
'settings.language.label': 'Interface language',
'settings.language.hint': 'Applied immediately',
'settings.language.ru': 'Русский',
'settings.language.en': 'English',
'settings.loading': 'Loading…',
// Updater
'updater.unsupported': 'Auto-update unavailable',
'updater.unsupported.reason_dev': 'Auto-update is disabled in dev mode',
'updater.checking': 'Checking for updates…',
'updater.up_to_date': 'Up to date',
'updater.up_to_date.subtitle': 'Current: v{v}',
'updater.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
'updater.downloaded.title': 'Ready · v{v}',
'updater.downloaded.subtitle': 'Restart to apply',
'updater.error.title': 'Check failed',
'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour',
// Reminder window
'reminder.kicker': 'Workout time',
'reminder.subkicker': 'Move',
'reminder.reps': 'reps',
'reminder.next_in': 'Next in {interval}',
'reminder.btn.done': 'Done',
'match.title.won': 'Victory',
'match.title.lost': 'Defeat',
'match.title.draw': 'Match finished',
'match.summary.minutes_count': '{n} min',
'match.summary.challenges_one': '{n} challenge',
'match.summary.challenges_few': '{n} challenges',
'match.summary.challenges_many': '{n} challenges',
'match.summary.all_done': 'all done',
'match.summary.remaining': '{n} left',
'match.total': 'Total',
'match.total_reps_suffix': 'reps',
// Format helpers
'fmt.now': 'now',
'fmt.h': 'h',
'fmt.m': 'min',
'fmt.h_short': 'h',
'fmt.m_short': 'm',
'fmt.s_short': 's',
'fmt.paused': 'paused',
'fmt.through': 'In'
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { translate, translateN } from './index'
describe('translate', () => {
it('returns the matching string by key', () => {
expect(translate('ru', 'btn.save')).toBe('Сохранить')
expect(translate('en', 'btn.save')).toBe('Save')
})
it('falls back to the key when missing', () => {
expect(translate('ru', 'totally.unknown.key')).toBe('totally.unknown.key')
})
it('substitutes single variable', () => {
expect(translate('ru', 'btn.snooze_min', { n: 5 })).toBe('Отложить 5 мин')
expect(translate('en', 'btn.snooze_min', { n: 10 })).toBe('Snooze 10m')
})
it('substitutes multiple variables', () => {
expect(
translate('en', 'updater.downloading.subtitle', {
got: '1.5',
total: '80.0',
speed: '2.5'
})
).toBe('1.5 / 80.0 MB · 2.5 MB/s')
})
it('handles unknown language with fallback to ru', () => {
// @ts-expect-error testing fallback
expect(translate('fr', 'btn.save')).toBe('Сохранить')
})
})
describe('translateN (plural)', () => {
describe('russian plural rules', () => {
it('one: 1, 21, 101', () => {
expect(translateN('ru', 'match.summary.challenges', 1)).toBe('1 челлендж')
expect(translateN('ru', 'match.summary.challenges', 21)).toBe(
'21 челлендж'
)
expect(translateN('ru', 'match.summary.challenges', 101)).toBe(
'101 челлендж'
)
})
it('few: 2, 3, 4, 22, 23, 24', () => {
expect(translateN('ru', 'match.summary.challenges', 2)).toBe(
'2 челленджа'
)
expect(translateN('ru', 'match.summary.challenges', 3)).toBe(
'3 челленджа'
)
expect(translateN('ru', 'match.summary.challenges', 22)).toBe(
'22 челленджа'
)
})
it('many: 0, 5-20, 25-30, 111-114', () => {
expect(translateN('ru', 'match.summary.challenges', 0)).toBe(
'0 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 5)).toBe(
'5 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 11)).toBe(
'11 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 13)).toBe(
'13 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 20)).toBe(
'20 челленджей'
)
})
})
describe('english plural rules', () => {
it('one for 1', () => {
expect(translateN('en', 'match.summary.challenges', 1)).toBe(
'1 challenge'
)
})
it('many/other for anything else', () => {
expect(translateN('en', 'match.summary.challenges', 0)).toBe(
'0 challenges'
)
expect(translateN('en', 'match.summary.challenges', 2)).toBe(
'2 challenges'
)
expect(translateN('en', 'match.summary.challenges', 21)).toBe(
'21 challenges'
)
})
})
})

View File

@@ -0,0 +1,83 @@
import { useAppStore } from '../store/appStore'
import { ru, en, type Dict } from './dict'
import type { Language } from '@shared/types'
const dicts: Record<Language, Dict> = { ru, en }
export function getDict(lang: Language): Dict {
return dicts[lang] ?? ru
}
export type TVars = Record<string, string | number>
/**
* Look up a key in the dictionary, substitute `{var}` placeholders.
* Returns the key itself if not found — surfaces missing translations.
*/
export function translate(
lang: Language,
key: string,
vars?: TVars
): string {
const dict = getDict(lang)
let s = dict[key] ?? key
if (vars) {
for (const k of Object.keys(vars)) {
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k]))
}
}
return s
}
/**
* Russian CLDR plural categories — covers nominal forms.
* one → 1, 21, 31, 41… (но не 11)
* few → 2-4, 22-24… (но не 12-14)
* many → 0, 5-20, 25-30…
*/
function pluralRu(n: number): 'one' | 'few' | 'many' {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return 'one'
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few'
return 'many'
}
/**
* Plural lookup. Pass `keyBase` like `match.summary.challenges` — the
* function appends `_one`/`_few`/`_many` (ru) or `_one`/`_many` (en).
* The `n` value is exposed as `{n}` in the resulting string.
*/
export function translateN(
lang: Language,
keyBase: string,
n: number,
vars?: TVars
): string {
const form =
lang === 'ru'
? pluralRu(n)
: n === 1
? 'one'
: 'many'
return translate(lang, `${keyBase}_${form}`, { n, ...vars })
}
/* ---------------- React hook ---------------- */
export function useLang(): Language {
return useAppStore((s) => s.state?.settings?.language ?? 'ru')
}
export function useT(): {
t: (key: string, vars?: TVars) => string
tn: (keyBase: string, n: number, vars?: TVars) => string
lang: Language
} {
const lang = useLang()
return {
t: (key, vars) => translate(lang, key, vars),
tn: (keyBase, n, vars) => translateN(lang, keyBase, n, vars),
lang
}
}

View File

@@ -1,17 +1,29 @@
export function formatCountdown(ms: number): string {
if (ms <= 0) return 'сейчас'
import type { Language } from '@shared/types'
const SUFFIX = {
ru: { now: 'сейчас', h: 'ч', m: 'м', s: 'с', minLong: 'мин', hLong: 'ч' },
en: { now: 'now', h: 'h', m: 'm', s: 's', minLong: 'min', hLong: 'h' }
}
export function formatCountdown(ms: number, lang: Language = 'ru'): string {
const s = SUFFIX[lang] ?? SUFFIX.ru
if (ms <= 0) return s.now
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}ч ${String(m).padStart(2, '0')}м`
if (m > 0) return `${m}м ${String(s).padStart(2, '0')}с`
return `${s}с`
const sec = totalSec % 60
if (h > 0) return `${h}${s.h} ${String(m).padStart(2, '0')}${s.m}`
if (m > 0) return `${m}${s.m} ${String(sec).padStart(2, '0')}${s.s}`
return `${sec}${s.s}`
}
export function formatInterval(minutes: number): string {
if (minutes < 60) return `${minutes} мин`
export function formatInterval(
minutes: number,
lang: Language = 'ru'
): string {
const s = SUFFIX[lang] ?? SUFFIX.ru
if (minutes < 60) return `${minutes} ${s.minLong}`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m === 0 ? `${h} ч` : `${h} ч ${m} мин`
return m === 0 ? `${h} ${s.hLong}` : `${h} ${s.hLong} ${m} ${s.minLong}`
}

View File

@@ -6,13 +6,15 @@ import { Switch } from '../components/ui/Switch'
import { Modal } from '../components/ui/Modal'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { ICON_CHOICES, Icon } from '../lib/icon'
import { GAME_STATS, STAT_LABELS } from '@shared/types'
import { GAME_STATS, statLabel } from '@shared/types'
import type {
Challenge,
GameId,
GameStat,
GameStatus
GameStatus,
Language
} from '@shared/types'
import { useT } from '../i18n'
const GAME_NAMES: Record<GameId, string> = {
dota2: 'Dota 2'
@@ -35,6 +37,7 @@ export default function ChallengesPage(): JSX.Element {
const [games, setGames] = useState<GameStatus[]>([])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Challenge | null>(null)
const { t, lang } = useT()
useEffect(() => {
void window.api.listGames().then(setGames)
@@ -49,13 +52,15 @@ export default function ChallengesPage(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Правила за матч
{t('challenges.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Челленджи
{t('challenges.title')}
</h1>
<p className="text-[15px] text-text/65 mt-2 font-medium">
Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
{t('challenges.subtitle', {
formula: t('challenges.subtitle.formula')
})}
</p>
</div>
<Button
@@ -64,7 +69,7 @@ export default function ChallengesPage(): JSX.Element {
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> Новый
<Plus size={15} strokeWidth={2.5} /> {t('btn.new')}
</Button>
</div>
@@ -74,15 +79,16 @@ export default function ChallengesPage(): JSX.Element {
<AlertTriangle size={18} strokeWidth={2.5} />
</div>
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
<span className="font-semibold text-text">«Игры»</span>.
{t('challenges.warning.no_games')}
</div>
</div>
)}
{challenges.length > 0 ? (
<>
<SectionHeader title={`Все · ${challenges.length}`} />
<SectionHeader
title={t('challenges.section.all', { n: challenges.length })}
/>
<Card>
{challenges.map((c, i) => (
<Row
@@ -111,7 +117,7 @@ export default function ChallengesPage(): JSX.Element {
<Gamepad2 size={12} strokeWidth={2.4} />
{GAME_NAMES[c.gameId]} ·{' '}
<span className="font-mono-num font-semibold text-text">
{STAT_LABELS[c.stat]} × {c.multiplier}
{statLabel(c.stat, lang)} × {c.multiplier}
</span>{' '}
{c.exerciseName}
</div>
@@ -129,8 +135,8 @@ export default function ChallengesPage(): JSX.Element {
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Челленджей пока нет. Привяжи упражнение к статистике матча.
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('challenges.empty')}
</div>
</Card>
)}
@@ -138,6 +144,7 @@ export default function ChallengesPage(): JSX.Element {
<ChallengeEditor
open={editorOpen}
challenge={editing}
lang={lang}
onClose={() => setEditorOpen(false)}
onSave={async (draft) => {
if (editing) await window.api.updateChallenge(editing.id, draft)
@@ -153,15 +160,18 @@ export default function ChallengesPage(): JSX.Element {
function ChallengeEditor({
open,
challenge,
lang,
onClose,
onSave
}: {
open: boolean
challenge: Challenge | null
lang: Language
onClose: () => void
onSave: (draft: Draft) => void
}): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
const { t } = useT()
useEffect(() => {
if (challenge) {
@@ -190,30 +200,34 @@ function ChallengeEditor({
<Modal
open={open}
onClose={onClose}
title={challenge ? 'Редактировать' : 'Новый челлендж'}
title={
challenge
? t('editor.challenge.title.edit')
: t('editor.challenge.title.new')
}
footer={
<>
<Button variant="plain" onClick={onClose}>
Отмена
{t('btn.cancel')}
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
{t('btn.save')}
</Button>
</>
}
>
<div className="space-y-5">
<Field label="Название">
<Field label={t('editor.field.challenge_name')}>
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="За смерти — приседания"
placeholder={t('editor.field.challenge_name.placeholder')}
className="ios-input"
autoFocus
/>
</Field>
<Field label="Игра">
<Field label={t('editor.field.game')}>
<select
value={draft.gameId}
onChange={(e) =>
@@ -230,7 +244,7 @@ function ChallengeEditor({
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Статистика">
<Field label={t('editor.field.stat')}>
<select
value={draft.stat}
onChange={(e) =>
@@ -240,12 +254,12 @@ function ChallengeEditor({
>
{GAME_STATS[draft.gameId].map((s) => (
<option key={s} value={s}>
{STAT_LABELS[s]}
{statLabel(s, lang)}
</option>
))}
</select>
</Field>
<Field label="Коэффициент">
<Field label={t('editor.field.multiplier')}>
<input
type="number"
step="0.5"
@@ -262,18 +276,18 @@ function ChallengeEditor({
</Field>
</div>
<Field label="Упражнение">
<Field label={t('editor.field.exercise_name')}>
<input
value={draft.exerciseName}
onChange={(e) =>
setDraft({ ...draft, exerciseName: e.target.value })
}
placeholder="Приседания"
placeholder={t('editor.field.exercise_name.placeholder')}
className="ios-input"
/>
</Field>
<Field label="Иконка">
<Field label={t('editor.field.icon')}>
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
{ICON_CHOICES.map((name) => (
<button
@@ -293,13 +307,12 @@ function ChallengeEditor({
</div>
</Field>
{/* Live preview */}
<div className="rounded-2xl bg-accent/8 p-4">
<div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
Превью · 5 событий
{t('editor.challenge.preview.kicker')}
</div>
<div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
<span>5 {STAT_LABELS[draft.stat]}</span>
<span>5 {statLabel(draft.stat, lang)}</span>
<span className="text-text/40">×</span>
<span>{draft.multiplier}</span>
<span className="text-text/40">=</span>
@@ -307,12 +320,12 @@ function ChallengeEditor({
{previewReps}
</span>
<span className="text-text/55">
{draft.exerciseName.toLowerCase() || 'повторов'}
{draft.exerciseName.toLowerCase() ||
t('editor.challenge.preview.fallback')}
</span>
</div>
</div>
</div>
<style>{`
.ios-input {
width: 100%;

View File

@@ -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>
)}

View File

@@ -7,12 +7,14 @@ import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format'
import { useT } from '../i18n'
import type { Exercise } from '@shared/types'
export default function Exercises(): JSX.Element {
const exercises = useAppStore((s) => s.state?.exercises ?? [])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const { t, lang } = useT()
const enabled = exercises.filter((e) => e.enabled)
const disabled = exercises.filter((e) => !e.enabled)
@@ -23,10 +25,10 @@ export default function Exercises(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Программа
{t('exercises.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Упражнения
{t('exercises.title')}
</h1>
</div>
<Button
@@ -35,19 +37,25 @@ export default function Exercises(): JSX.Element {
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> Добавить
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
</div>
{enabled.length > 0 && (
<>
<SectionHeader title={`Активные · ${enabled.length}`} />
<SectionHeader
title={t('exercises.section.active', { n: enabled.length })}
/>
<Card className="mb-6">
{enabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
exercise={ex}
last={i === enabled.length - 1}
meta={t('exercises.row.meta', {
reps: ex.reps,
interval: formatInterval(ex.intervalMinutes, lang)
})}
onEdit={() => {
setEditing(ex)
setEditorOpen(true)
@@ -60,13 +68,19 @@ export default function Exercises(): JSX.Element {
{disabled.length > 0 && (
<>
<SectionHeader title={`Выключенные · ${disabled.length}`} />
<SectionHeader
title={t('exercises.section.disabled', { n: disabled.length })}
/>
<Card>
{disabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
exercise={ex}
last={i === disabled.length - 1}
meta={t('exercises.row.meta', {
reps: ex.reps,
interval: formatInterval(ex.intervalMinutes, lang)
})}
onEdit={() => {
setEditing(ex)
setEditorOpen(true)
@@ -80,7 +94,7 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
Программа пуста добавь первое упражнение
{t('exercises.empty')}
</div>
</Card>
)}
@@ -103,15 +117,16 @@ export default function Exercises(): JSX.Element {
function ExerciseRow({
exercise,
last,
meta,
onEdit
}: {
exercise: Exercise
last: boolean
meta: string
onEdit: () => void
}): JSX.Element {
return (
<Row last={last}>
{/* Tinted icon plaque, iOS Settings style */}
<div
className={[
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
@@ -129,19 +144,15 @@ function ExerciseRow({
<div className="text-[16px] font-semibold truncate leading-tight">
{exercise.name}
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{exercise.reps} раз · {formatInterval(exercise.intervalMinutes)}
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">{meta}</div>
</button>
<Switch
checked={exercise.enabled}
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
aria-label="Включить/выключить"
/>
<button
onClick={onEdit}
className="text-text/30 hover:text-text/60 transition-colors"
aria-label="Редактировать"
>
<ChevronRight size={16} />
</button>

View File

@@ -13,10 +13,12 @@ import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n'
export default function GamesPage(): JSX.Element {
const [games, setGames] = useState<GameStatus[]>([])
const [busy, setBusy] = useState<GameId | null>(null)
const { t } = useT()
useEffect(() => {
void refresh()
@@ -62,29 +64,29 @@ export default function GamesPage(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Трекинг матчей
{t('games.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Игры
{t('games.title')}
</h1>
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
Подключи игру челленджи сработают сразу после матча
{t('games.subtitle')}
{liveCount > 0 && (
<>
{' · '}
<span className="text-success font-mono-num font-bold">
{liveCount} live
{t('games.subtitle.live', { n: liveCount })}
</span>
</>
)}
</p>
</div>
<Button variant="tinted" onClick={refresh}>
<RefreshCw size={14} strokeWidth={2.5} /> Обновить
<RefreshCw size={14} strokeWidth={2.5} /> {t('btn.refresh')}
</Button>
</div>
<SectionHeader title="Поддерживаемые" />
<SectionHeader title={t('games.section.supported')} />
<div className="space-y-4">
{games.map((g, i) => (
<motion.div
@@ -105,7 +107,7 @@ export default function GamesPage(): JSX.Element {
{games.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Сканируем установленные игры
{t('games.scanning')}
</div>
</Card>
)}
@@ -130,6 +132,7 @@ function GameCard({
onUninstall: () => void
onToggle: (v: boolean) => void
}): JSX.Element {
const { t } = useT()
const isLive =
game.installed &&
game.integrationActive &&
@@ -183,11 +186,9 @@ function GameCard({
strokeWidth={2.4}
/>
<div className="text-text/85">
Steam запущен. Параметр{' '}
<code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam.
{t('games.queued.body', {
opt: game.launchOption ?? '-gamestateintegration'
})}
</div>
</div>
)}
@@ -199,18 +200,14 @@ function GameCard({
className="text-destructive shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/85">
В Steam нет залогиненного аккаунта (нет папки{' '}
<code className="font-mono-num text-[13px] font-semibold">userdata</code>).
Запусти Steam один раз и нажми «Установить интеграцию».
</div>
<div className="text-text/85">{t('games.no_user.body')}</div>
</div>
)}
<div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> Подключить
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')}
</Button>
)}
{game.integrationActive && (
@@ -220,12 +217,12 @@ function GameCard({
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> Отключить
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')}
</Button>
)}
{!game.installed && (
<div className="text-[14px] text-text/65 font-medium">
Установи игру в Steam и нажми «Обновить»
{t('games.not_installed.hint')}
</div>
)}
</div>
@@ -240,6 +237,7 @@ function StatusBadge({
game: GameStatus
isLive: boolean
}): JSX.Element {
const { t } = useT()
if (isLive) {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-success/15 text-success font-semibold inline-flex items-center gap-1.5">
@@ -247,40 +245,41 @@ function StatusBadge({
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Live
{t('games.badge.live')}
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'applied') {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
<CheckCircle2 size={11} strokeWidth={2.5} /> Готово
<CheckCircle2 size={11} strokeWidth={2.5} /> {t('games.badge.ready')}
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'queued') {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
В очереди
{t('games.badge.queued')}
</span>
)
}
if (game.installed) {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
Установлена
{t('games.badge.installed')}
</span>
)
}
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
Не найдена
{t('games.badge.not_found')}
</span>
)
}
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const [open, setOpen] = useState(false)
const { t } = useT()
const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null
return (
@@ -289,15 +288,15 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
onClick={() => setOpen(!open)}
className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
>
{open ? '▾' : '▸'} dev · симулировать конец матча
{open ? '▾' : '▸'} {t('games.dev.toggle')}
</button>
{open && (
<div className="mt-3 flex flex-wrap gap-2">
{(
[
{ label: '5 смертей', stats: { deaths: 5 } },
{ label: '10 смертей', stats: { deaths: 10 } },
{ label: '15 убийств', stats: { kills: 15 } },
{ label: '5 deaths', stats: { deaths: 5 } },
{ label: '10 deaths', stats: { deaths: 10 } },
{ label: '15 kills', stats: { kills: 15 } },
{
label: 'KDA 8/3/12',
stats: { kills: 8, deaths: 3, assists: 12 }

View File

@@ -2,7 +2,9 @@ import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { useT } from '../i18n'
import type {
Language,
NotificationMode,
Settings as SettingsType,
Theme
@@ -10,8 +12,9 @@ import type {
export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const { t } = useT()
if (!settings)
return <div className="p-8 text-text/45">Загрузка</div>
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -22,67 +25,89 @@ export default function SettingsPage(): JSX.Element {
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-8">
<div className="text-[14px] text-text/65 font-semibold">
Конфигурация
{t('settings.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Настройки
{t('settings.title')}
</h1>
</div>
{/* Reminders */}
<SectionHeader title="Напоминания" />
<SectionHeader title={t('settings.section.language')} />
<Card className="mb-6">
<SelectRow
label="Режим уведомления"
hint="Как должно выглядеть напоминание"
value={settings.notificationMode}
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
label={t('settings.language.label')}
hint={t('settings.language.hint')}
value={settings.language}
onChange={(v) => patch({ language: v as Language })}
options={[
{ value: 'modal', label: 'Окно поверх всех' },
{ value: 'toast', label: 'Системное уведомление' },
{ value: 'both', label: 'Окно и уведомление' }
]}
/>
<ToggleRow
label="Звук уведомления"
hint="Короткий сигнал при срабатывании"
checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })}
/>
<SelectRow
label="«Отложить» на"
hint="Сколько минут добавлять при отложении"
value={String(settings.snoozeMinutes)}
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
options={[
{ value: '1', label: '1 минута' },
{ value: '5', label: '5 минут' },
{ value: '10', label: '10 минут' },
{ value: '15', label: '15 минут' },
{ value: '30', label: '30 минут' }
{ value: 'ru', label: t('settings.language.ru') },
{ value: 'en', label: t('settings.language.en') }
]}
last
/>
</Card>
{/* Window */}
<SectionHeader title="Окно и трей" />
<SectionHeader title={t('settings.section.reminders')} />
<Card className="mb-6">
<SelectRow
label={t('settings.notification_mode.label')}
hint={t('settings.notification_mode.hint')}
value={settings.notificationMode}
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
options={[
{
value: 'modal',
label: t('settings.notification_mode.modal')
},
{
value: 'toast',
label: t('settings.notification_mode.toast')
},
{
value: 'both',
label: t('settings.notification_mode.both')
}
]}
/>
<ToggleRow
label={t('settings.sound.label')}
hint={t('settings.sound.hint')}
checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })}
/>
<SelectRow
label={t('settings.snooze.label')}
hint={t('settings.snooze.hint')}
value={String(settings.snoozeMinutes)}
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
options={[
{ value: '1', label: t('settings.snooze.1') },
{ value: '5', label: t('settings.snooze.5') },
{ value: '10', label: t('settings.snooze.10') },
{ value: '15', label: t('settings.snooze.15') },
{ value: '30', label: t('settings.snooze.30') }
]}
last
/>
</Card>
<SectionHeader title={t('settings.section.window')} />
<Card className="mb-6">
<ToggleRow
label="Сворачивать в трей"
hint="При закрытии остаётся работать в фоне"
label={t('settings.tray.label')}
hint={t('settings.tray.hint')}
checked={settings.minimizeToTray}
onChange={(v) => patch({ minimizeToTray: v })}
/>
<ToggleRow
label="Запускать с Windows"
hint="Открывать при входе в систему"
label={t('settings.autostart.label')}
hint={t('settings.autostart.hint')}
checked={settings.startWithWindows}
onChange={(v) => patch({ startWithWindows: v })}
/>
<ToggleRow
label="Запускать свёрнутым"
hint="При автозапуске открывать сразу в трее"
label={t('settings.start_minimized.label')}
hint={t('settings.start_minimized.hint')}
checked={settings.startMinimized}
onChange={(v) => patch({ startMinimized: v })}
disabled={!settings.startWithWindows}
@@ -90,24 +115,23 @@ export default function SettingsPage(): JSX.Element {
/>
</Card>
{/* Appearance */}
<SectionHeader title="Внешний вид" />
<SectionHeader title={t('settings.section.appearance')} />
<Card className="mb-6">
<SelectRow
label="Тема"
hint="Светлая / тёмная / как в системе"
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: 'Как в системе' },
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
{ value: 'system', label: t('settings.theme.system') },
{ value: 'light', label: t('settings.theme.light') },
{ value: 'dark', label: t('settings.theme.dark') }
]}
last
/>
</Card>
<SectionHeader title="Обновления" />
<SectionHeader title={t('settings.section.updates')} />
<UpdaterCard />
</div>
</div>

View File

@@ -11,6 +11,7 @@ export type Exercise = {
export type NotificationMode = 'toast' | 'modal' | 'both'
export type Theme = 'light' | 'dark' | 'system'
export type Language = 'ru' | 'en'
export type Settings = {
globalEnabled: boolean
@@ -20,6 +21,7 @@ export type Settings = {
minimizeToTray: boolean
startMinimized: boolean
theme: Theme
language: Language
snoozeMinutes: number
}
@@ -71,6 +73,19 @@ export const STAT_LABELS: Record<GameStat, string> = {
duration_min: 'минут матча'
}
export const STAT_LABELS_EN: Record<GameStat, string> = {
deaths: 'deaths',
kills: 'kills',
assists: 'assists',
last_hits: 'last hits',
denies: 'denies',
duration_min: 'match minutes'
}
export function statLabel(stat: GameStat, lang: Language): string {
return (lang === 'en' ? STAT_LABELS_EN : STAT_LABELS)[stat]
}
export type Challenge = {
id: string
name: string
@@ -103,7 +118,10 @@ export type ChallengeResult = {
exerciseName: string
reps: number
statValue: number
/** Pre-localised label for backward compat; renderer prefers `stat`. */
statLabel: string
/** Stat key; renderer uses this to localise on demand. */
stat?: GameStat
}
export type MatchSummary = {
@@ -122,6 +140,7 @@ export const DEFAULT_SETTINGS: Settings = {
minimizeToTray: true,
startMinimized: false,
theme: 'light',
language: 'ru',
snoozeMinutes: 5
}