Initial commit

This commit is contained in:
AnRil
2026-05-16 13:43:29 +07:00
commit 688a86b611
208 changed files with 44350 additions and 0 deletions

16
src/renderer/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; script-src 'self'" />
<title>Exercise Reminder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { HashRouter, Route, Routes } from 'react-router-dom'
import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises'
import GamesPage from './pages/Games'
import ChallengesPage from './pages/Challenges'
import SettingsPage from './pages/Settings'
import { subscribeToBackend, useAppStore } from './store/appStore'
export default function App(): JSX.Element {
const hydrated = useAppStore((s) => s.hydrated)
useEffect(() => {
const unsub = subscribeToBackend()
return unsub
}, [])
return (
<HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar title="Exercise Reminder" />
<div className="flex-1 flex overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-hidden">
{hydrated ? (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/exercises" element={<Exercises />} />
<Route path="/games" element={<GamesPage />} />
<Route path="/challenges" element={<ChallengesPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
) : (
<div className="p-8 text-muted">Загрузка</div>
)}
</main>
</div>
</div>
</HashRouter>
)
}

View File

@@ -0,0 +1,326 @@
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { Check, Clock, X, Trophy, Skull, Gamepad2 } from 'lucide-react'
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
import { Icon } from './lib/icon'
import { formatInterval } from './lib/format'
type Mode =
| { kind: 'idle' }
| { kind: 'exercise'; exercise: Exercise }
| { kind: 'match'; summary: MatchSummary; done: Set<string> }
export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
window.api.getState().then((s) => setSettings(s.settings))
const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
const u1 = window.api.onFire((ex) => {
setMode({ kind: 'exercise', exercise: ex })
if (settingsRef.current?.soundEnabled) playBeep()
})
const u2 = window.api.onMatchEnd((summary) => {
setMode({ kind: 'match', summary, done: new Set() })
if (settingsRef.current?.soundEnabled) playBeep()
})
return () => {
u0()
u1()
u2()
}
}, [])
function close(): void {
setMode({ kind: 'idle' })
window.api.reminderClose()
}
if (mode.kind === 'idle') return <div className="reminder-shell" />
if (mode.kind === 'exercise') {
return (
<ExerciseReminder
exercise={mode.exercise}
snoozeMinutes={settings?.snoozeMinutes ?? 5}
onClose={close}
/>
)
}
return (
<MatchSummaryView
summary={mode.summary}
done={mode.done}
onMarkDone={(id) =>
setMode({
kind: 'match',
summary: mode.summary,
done: new Set([...mode.done, id])
})
}
onClose={close}
/>
)
}
function ExerciseReminder({
exercise,
snoozeMinutes,
onClose
}: {
exercise: Exercise
snoozeMinutes: number
onClose: () => void
}): JSX.Element {
async function done(): Promise<void> {
await window.api.markDone(exercise.id)
onClose()
}
async function snooze(): Promise<void> {
await window.api.snooze(exercise.id, snoozeMinutes)
onClose()
}
async function skip(): Promise<void> {
await window.api.skip(exercise.id)
onClose()
}
return (
<div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-8 px-3 flex items-center justify-end">
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
aria-label="Закрыть"
>
<X size={13} />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
className="relative mb-5"
>
<div className="absolute inset-0 rounded-full bg-accent/30 animate-pulse-ring" />
<div className="relative w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-glow">
<Icon name={exercise.icon} size={44} />
</div>
</motion.div>
<div className="text-xs uppercase tracking-[0.2em] text-muted">Время размяться</div>
<h1 className="text-3xl font-bold mt-2 mb-1">{exercise.name}</h1>
<div className="text-5xl font-extrabold text-accent tabular-nums mt-1">
{exercise.reps}
<span className="text-base font-medium text-muted ml-2">раз</span>
</div>
<div className="text-xs text-muted mt-2">
Следующее напоминание через {formatInterval(exercise.intervalMinutes)}
</div>
</div>
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
<button
onClick={skip}
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-muted hover:text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
>
<X size={14} /> Пропустить
</button>
<button
onClick={snooze}
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
>
<Clock size={14} /> Отложить {snoozeMinutes}м
</button>
<button
onClick={done}
className="h-12 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center justify-center gap-1.5 shadow-glow"
>
<Check size={16} /> Сделал
</button>
</div>
</div>
)
}
function MatchSummaryView({
summary,
done,
onMarkDone,
onClose
}: {
summary: MatchSummary
done: Set<string>
onMarkDone: (id: string) => void
onClose: () => void
}): JSX.Element {
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
.filter((r) => !done.has(r.challengeId))
.reduce((s, r) => s + r.reps, 0)
return (
<div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
<div className="text-xs text-muted inline-flex items-center gap-1.5 px-2">
<Gamepad2 size={12} /> {summary.gameName}
</div>
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
aria-label="Закрыть"
>
<X size={13} />
</button>
</div>
<div className="px-6 pt-2 pb-4 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/15 text-accent mb-3"
>
{summary.won === true ? (
<Trophy size={28} />
) : summary.won === false ? (
<Skull size={28} />
) : (
<Gamepad2 size={28} />
)}
</motion.div>
<h1 className="text-xl font-bold">
{summary.won === true
? 'Победа! Время заработанных упражнений'
: summary.won === false
? 'Поражение. Но тело — нет'
: 'Матч завершён'}
</h1>
<p className="text-xs text-muted mt-1">
{Math.floor(summary.durationMs / 60_000)} мин · {summary.results.length}{' '}
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
{allDone ? (
<span className="text-emerald-500 font-medium">всё выполнено</span>
) : (
<span className="text-accent font-semibold">{remainingReps} ещё</span>
)}
</p>
</div>
<div className="flex-1 overflow-y-auto px-4 space-y-2">
{summary.results.map((r) => (
<ChallengeRow
key={r.challengeId}
result={r}
done={done.has(r.challengeId)}
onMarkDone={() => onMarkDone(r.challengeId)}
/>
))}
</div>
<div className="px-6 pb-6 pt-3 flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
Всего: <span className="text-text font-semibold">{totalReps}</span>{' '}
повторений
</div>
<button
onClick={onClose}
className="h-11 px-5 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center gap-1.5 shadow-glow"
>
{allDone ? (
<>
<Check size={16} /> Готово
</>
) : (
'Позже'
)}
</button>
</div>
</div>
)
}
function ChallengeRow({
result,
done,
onMarkDone
}: {
result: ChallengeResult
done: boolean
onMarkDone: () => void
}): JSX.Element {
return (
<motion.div
layout
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
className={[
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
done
? 'border-emerald-500/40 bg-emerald-500/10'
: 'border-border bg-surface-elevated'
].join(' ')}
>
<div
className={[
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
done ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent'
].join(' ')}
>
<Icon name={result.icon} size={22} />
</div>
<div className="flex-1 min-w-0">
<div className={['font-medium truncate', done ? 'line-through opacity-60' : ''].join(' ')}>
{result.exerciseName}
</div>
<div className="text-xs text-muted mt-0.5">
{result.statValue} {result.statLabel} {result.name}
</div>
</div>
<div className={['text-2xl font-bold tabular-nums', done ? 'text-emerald-500' : 'text-accent'].join(' ')}>
{result.reps}
</div>
<button
onClick={onMarkDone}
disabled={done}
className={[
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
done
? 'bg-emerald-500 text-white cursor-default'
: 'bg-accent text-white hover:brightness-110'
].join(' ')}
aria-label="Готово"
>
<Check size={16} />
</button>
</motion.div>
)
}
function playBeep(): void {
try {
const Ctx =
(window.AudioContext as typeof AudioContext | undefined) ||
((window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext as typeof AudioContext)
const ctx = new Ctx()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.frequency.value = 660
osc.type = 'sine'
gain.gain.setValueAtTime(0, ctx.currentTime)
gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.02)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45)
osc.connect(gain).connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.5)
osc.onended = () => ctx.close()
} catch {
// ignore
}
}

View File

@@ -0,0 +1,116 @@
import { motion } from 'framer-motion'
import { Check, Pencil, Trash2 } from 'lucide-react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format'
import { Switch } from './ui/Switch'
type Props = {
exercise: Exercise
tick?: Tick
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
onMarkDone: () => void
}
export function ExerciseCard({
exercise,
tick,
onEdit,
onDelete,
onToggle,
onMarkDone
}: Props): JSX.Element {
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
const progressPct = (() => {
const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms))
const elapsed = total - remaining
return Math.max(0, Math.min(100, (elapsed / total) * 100))
})()
const isDue = ms <= 0 && exercise.enabled
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className={[
'group rounded-2xl border bg-surface p-5 flex flex-col gap-4 transition-shadow',
isDue ? 'border-accent shadow-glow' : 'border-border hover:shadow-soft'
].join(' ')}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className={[
'w-12 h-12 rounded-xl grid place-items-center',
exercise.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={exercise.icon} size={22} />
</div>
<div>
<div className="font-semibold leading-tight">{exercise.name}</div>
<div className="text-xs text-muted mt-0.5">
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
</div>
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label="Включить/выключить"
/>
</div>
<div>
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs uppercase tracking-wider text-muted">
Следующее
</span>
<span
className={[
'text-sm font-mono font-semibold tabular-nums',
isDue ? 'text-accent' : 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
</span>
</div>
<div className="h-1.5 rounded-full bg-surface-elevated overflow-hidden">
<motion.div
className="h-full rounded-full bg-accent"
animate={{ width: `${exercise.enabled ? progressPct : 0}%` }}
transition={{ duration: 0.5, ease: 'linear' }}
/>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onMarkDone}
className="flex-1 h-9 rounded-lg bg-accent/10 hover:bg-accent/20 text-accent text-xs font-medium inline-flex items-center justify-center gap-1.5"
>
<Check size={14} /> Сделал сейчас
</button>
<button
onClick={onEdit}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
aria-label="Редактировать"
>
<Pencil size={14} />
</button>
<button
onClick={onDelete}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
aria-label="Удалить"
>
<Trash2 size={14} />
</button>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import type { Exercise } from '@shared/types'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon'
type Draft = {
name: string
reps: number
icon: string
intervalMinutes: number
enabled: boolean
}
const EMPTY: Draft = {
name: '',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true
}
type Props = {
open: boolean
exercise?: Exercise | null
onClose: () => void
onSave: (draft: Draft) => void
}
export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY)
useEffect(() => {
if (exercise) {
setDraft({
name: exercise.name,
reps: exercise.reps,
icon: exercise.icon,
intervalMinutes: exercise.intervalMinutes,
enabled: exercise.enabled
})
} else {
setDraft(EMPTY)
}
}, [exercise, open])
const canSave = draft.name.trim().length > 0 && draft.intervalMinutes >= 1
return (
<Modal
open={open}
onClose={onClose}
title={exercise ? 'Редактировать упражнение' : 'Новое упражнение'}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Отмена
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
</Button>
</>
}
>
<div className="space-y-5">
<Field label="Название">
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Например, приседания"
className="input"
autoFocus
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Повторений">
<input
type="number"
min={1}
value={draft.reps}
onChange={(e) =>
setDraft({ ...draft, reps: Math.max(1, Number(e.target.value) || 1) })
}
className="input"
/>
</Field>
<Field label="Интервал (мин)">
<input
type="number"
min={1}
value={draft.intervalMinutes}
onChange={(e) =>
setDraft({
...draft,
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
})
}
className="input"
/>
</Field>
</div>
<Field label="Иконка">
<div className="grid grid-cols-9 gap-2 max-h-48 overflow-y-auto pr-1">
{ICON_CHOICES.map((name) => (
<button
key={name}
type="button"
onClick={() => setDraft({ ...draft, icon: name })}
className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
draft.icon === name
? 'border-accent bg-accent/15 text-accent'
: 'border-border bg-surface-elevated text-muted hover:text-text'
].join(' ')}
>
<Icon name={name} size={18} />
</button>
))}
</div>
</Field>
</div>
<style>{`
.input {
width: 100%;
height: 40px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid rgb(var(--border));
background: rgb(var(--surface-elevated));
color: rgb(var(--text));
font-size: 14px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.input:focus {
border-color: rgb(var(--accent));
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
}
`}</style>
</Modal>
)
}
function Field({
label,
children
}: {
label: string
children: React.ReactNode
}): JSX.Element {
return (
<label className="block">
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
{label}
</span>
{children}
</label>
)
}

View File

@@ -0,0 +1,57 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard,
ListChecks,
Gamepad2,
Target,
Settings as SettingsIcon
} from 'lucide-react'
const links = [
{ to: '/', label: 'Дашборд', icon: LayoutDashboard, end: true },
{ to: '/exercises', label: 'Упражнения', icon: ListChecks },
{ to: '/games', label: 'Игры', icon: Gamepad2 },
{ to: '/challenges', label: 'Челленджи', icon: Target },
{ to: '/settings', label: 'Настройки', icon: SettingsIcon }
]
export function Sidebar(): JSX.Element {
return (
<aside className="w-56 shrink-0 border-r border-border/60 bg-surface/40 flex flex-col">
<div className="px-5 py-5">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-accent grid place-items-center text-white font-bold shadow-glow">
R
</div>
<div>
<div className="font-semibold text-sm leading-tight">Reminder</div>
<div className="text-xs text-muted">Будь в движении</div>
</div>
</div>
</div>
<nav className="px-3 flex flex-col gap-1">
{links.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-accent/15 text-accent'
: 'text-muted hover:text-text hover:bg-surface-elevated'
].join(' ')
}
>
<Icon size={18} />
{label}
</NavLink>
))}
</nav>
<div className="mt-auto p-4 text-xs text-muted">
<div className="opacity-60">v0.1 · Windows 11</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,35 @@
import { Minus, X, Square } from 'lucide-react'
export function Titlebar({ title }: { title: string }): JSX.Element {
return (
<div className="titlebar-drag h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/60 backdrop-blur">
<div className="flex items-center gap-2 text-sm font-medium text-muted">
<span className="w-2 h-2 rounded-full bg-accent shadow-glow" />
<span>{title}</span>
</div>
<div className="titlebar-nodrag flex items-center gap-1">
<button
onClick={() => window.api.minimizeMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
aria-label="Свернуть"
>
<Minus size={14} />
</button>
<button
onClick={() => window.api.hideMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
aria-label="Скрыть в трей"
>
<Square size={12} />
</button>
<button
onClick={() => window.api.closeMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white transition-colors text-muted"
aria-label="Закрыть"
>
<X size={14} />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { ButtonHTMLAttributes, forwardRef } from 'react'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
type Size = 'sm' | 'md' | 'lg'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant
size?: Size
}
const variantClasses: Record<Variant, string> = {
primary:
'bg-accent text-white hover:brightness-110 active:brightness-95 shadow-soft',
secondary:
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border',
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
danger: 'bg-red-500 text-white hover:bg-red-600 shadow-soft'
}
const sizeClasses: Record<Size, string> = {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base'
}
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = 'primary', size = 'md', className = '', ...rest },
ref
) {
return (
<button
ref={ref}
className={[
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
variantClasses[variant],
sizeClasses[size],
className
].join(' ')}
{...rest}
/>
)
})

View File

@@ -0,0 +1,81 @@
import { AnimatePresence, motion } from 'framer-motion'
import { X } from 'lucide-react'
import { ReactNode, useEffect } from 'react'
type Props = {
open: boolean
onClose: () => void
title: string
children: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg'
}
const sizeClass = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl'
}
export function Modal({
open,
onClose,
title,
children,
footer,
size = 'md'
}: Props): JSX.Element {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 grid place-items-center bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
className={[
'w-full mx-4 bg-surface rounded-2xl border border-border shadow-soft flex flex-col',
sizeClass[size]
].join(' ')}
initial={{ scale: 0.96, y: 12, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.96, y: 8, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
<h3 className="font-semibold text-base">{title}</h3>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text"
aria-label="Закрыть"
>
<X size={16} />
</button>
</div>
<div className="px-5 py-4 overflow-y-auto max-h-[70vh]">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-border/60 flex justify-end gap-2">
{footer}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,32 @@
type Props = {
checked: boolean
onChange: (next: boolean) => void
disabled?: boolean
'aria-label'?: string
}
export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Element {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={rest['aria-label']}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors duration-200 outline-none',
checked ? 'bg-accent' : 'bg-border',
disabled ? 'opacity-50 cursor-not-allowed' : ''
].join(' ')}
>
<span
className={[
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform duration-200',
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
'mt-0.5'
].join(' ')}
/>
</button>
)
}

View File

@@ -0,0 +1,17 @@
export function formatCountdown(ms: number): string {
if (ms <= 0) return 'сейчас'
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}с`
}
export function formatInterval(minutes: number): string {
if (minutes < 60) return `${minutes} мин`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m === 0 ? `${h} ч` : `${h} ч ${m} мин`
}

View File

@@ -0,0 +1,36 @@
import * as Lucide from 'lucide-react'
import type { LucideProps } from 'lucide-react'
export const ICON_CHOICES = [
'Activity',
'Dumbbell',
'StretchHorizontal',
'PersonStanding',
'Heart',
'Footprints',
'Hand',
'Eye',
'Brain',
'Bike',
'Waves',
'Wind',
'Sun',
'Coffee',
'Apple',
'GlassWater',
'BookOpen',
'Sparkles'
] as const
export type IconName = (typeof ICON_CHOICES)[number]
export function Icon({
name,
...props
}: { name: string } & LucideProps): JSX.Element {
const Cmp = (Lucide as unknown as Record<string, React.ComponentType<LucideProps>>)[
name
]
if (!Cmp) return <Lucide.Activity {...props} />
return <Cmp {...props} />
}

15
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './styles/globals.css'
import App from './App'
import ReminderApp from './ReminderApp'
import { ThemeProvider } from './providers/ThemeProvider'
const params = new URLSearchParams(window.location.search)
const which = params.get('window') ?? 'main'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>{which === 'reminder' ? <ReminderApp /> : <App />}</ThemeProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,312 @@
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, Gamepad2 } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Modal } from '../components/ui/Modal'
import { ICON_CHOICES, Icon } from '../lib/icon'
import { GAME_STATS, STAT_LABELS } from '@shared/types'
import type { Challenge, GameId, GameStat, GameStatus } from '@shared/types'
const GAME_NAMES: Record<GameId, string> = {
dota2: 'Dota 2'
}
type Draft = Omit<Challenge, 'id'>
const EMPTY_DRAFT: Draft = {
name: '',
gameId: 'dota2',
stat: 'deaths',
multiplier: 3,
exerciseName: 'Приседания',
icon: 'Activity',
enabled: true
}
export default function ChallengesPage(): JSX.Element {
const challenges = useAppStore((s) => s.state?.challenges ?? [])
const [games, setGames] = useState<GameStatus[]>([])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Challenge | null>(null)
useEffect(() => {
void window.api.listGames().then(setGames)
return window.api.onGamesChanged(setGames)
}, [])
return (
<div className="p-8 overflow-y-auto h-full max-w-3xl">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Челленджи</h1>
<p className="text-sm text-muted mt-1">
После матча повторений = статистика × коэффициент
</p>
</div>
<Button
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={16} /> Новый
</Button>
</div>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
{challenges.map((c, i) => (
<div
key={c.id}
className={[
'flex items-center gap-4 px-5 py-4',
i < challenges.length - 1 ? 'border-b border-border/60' : ''
].join(' ')}
>
<div
className={[
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
c.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={c.icon} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{c.name}</div>
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5">
<Gamepad2 size={12} /> {GAME_NAMES[c.gameId]} ·{' '}
{STAT_LABELS[c.stat]} × {c.multiplier} = {c.exerciseName}
</div>
</div>
<Switch
checked={c.enabled}
onChange={(v) => window.api.toggleChallenge(c.id, v)}
/>
<button
onClick={() => {
setEditing(c)
setEditorOpen(true)
}}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
aria-label="Редактировать"
>
<Pencil size={15} />
</button>
<button
onClick={() => window.api.deleteChallenge(c.id)}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
))}
{challenges.length === 0 && (
<div className="px-5 py-12 text-center text-muted">
Челленджей пока нет. Добавь первый.
</div>
)}
</div>
{games.length > 0 && !games.some((g) => g.enabled) && (
<div className="mt-6 rounded-xl bg-amber-500/10 border border-amber-500/30 p-4 text-sm">
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '}
<strong>Игры</strong>.
</div>
)}
<ChallengeEditor
open={editorOpen}
challenge={editing}
onClose={() => setEditorOpen(false)}
onSave={async (draft) => {
if (editing) await window.api.updateChallenge(editing.id, draft)
else await window.api.addChallenge(draft)
setEditorOpen(false)
}}
/>
</div>
)
}
function ChallengeEditor({
open,
challenge,
onClose,
onSave
}: {
open: boolean
challenge: Challenge | null
onClose: () => void
onSave: (draft: Draft) => void
}): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
useEffect(() => {
if (challenge) {
setDraft({
name: challenge.name,
gameId: challenge.gameId,
stat: challenge.stat,
multiplier: challenge.multiplier,
exerciseName: challenge.exerciseName,
icon: challenge.icon,
enabled: challenge.enabled
})
} else {
setDraft(EMPTY_DRAFT)
}
}, [challenge, open])
const canSave =
draft.name.trim().length > 0 &&
draft.exerciseName.trim().length > 0 &&
draft.multiplier > 0
const previewReps = Math.round(5 * draft.multiplier)
return (
<Modal
open={open}
onClose={onClose}
title={challenge ? 'Редактировать челлендж' : 'Новый челлендж'}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Отмена
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
</Button>
</>
}
>
<div className="space-y-5">
<Field label="Название">
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Например, за смерти — приседания"
className="input"
autoFocus
/>
</Field>
<Field label="Игра">
<select
value={draft.gameId}
onChange={(e) => setDraft({ ...draft, gameId: e.target.value as GameId })}
className="input"
>
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
<option key={id} value={id}>
{GAME_NAMES[id]}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Стат">
<select
value={draft.stat}
onChange={(e) => setDraft({ ...draft, stat: e.target.value as GameStat })}
className="input"
>
{GAME_STATS[draft.gameId].map((s) => (
<option key={s} value={s}>
{STAT_LABELS[s]}
</option>
))}
</select>
</Field>
<Field label="Коэффициент">
<input
type="number"
step="0.5"
min="0.5"
value={draft.multiplier}
onChange={(e) =>
setDraft({ ...draft, multiplier: Math.max(0.5, Number(e.target.value) || 1) })
}
className="input"
/>
</Field>
</div>
<Field label="Упражнение">
<input
value={draft.exerciseName}
onChange={(e) => setDraft({ ...draft, exerciseName: e.target.value })}
placeholder="Например, приседания"
className="input"
/>
</Field>
<Field label="Иконка">
<div className="grid grid-cols-9 gap-2 max-h-40 overflow-y-auto pr-1">
{ICON_CHOICES.map((name) => (
<button
key={name}
type="button"
onClick={() => setDraft({ ...draft, icon: name })}
className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
draft.icon === name
? 'border-accent bg-accent/15 text-accent'
: 'border-border bg-surface-elevated text-muted hover:text-text'
].join(' ')}
>
<Icon name={name} size={18} />
</button>
))}
</div>
</Field>
<div className="rounded-xl bg-surface-elevated p-4 text-sm text-muted">
Пример: 5 {STAT_LABELS[draft.stat]} × {draft.multiplier} ={' '}
<span className="text-accent font-semibold">{previewReps}</span>{' '}
{draft.exerciseName.toLowerCase()}
</div>
</div>
<style>{`
.input {
width: 100%;
height: 40px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid rgb(var(--border));
background: rgb(var(--surface-elevated));
color: rgb(var(--text));
font-size: 14px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.input:focus {
border-color: rgb(var(--accent));
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
}
select.input {
padding-right: 32px;
}
`}</style>
</Modal>
)
}
function Field({
label,
children
}: {
label: string
children: React.ReactNode
}): JSX.Element {
return (
<label className="block">
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
{label}
</span>
{children}
</label>
)
}

View File

@@ -0,0 +1,144 @@
import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { Plus, Pause, Play, Clock } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types'
import { formatCountdown } from '../lib/format'
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 exercises = state?.exercises ?? []
const settings = state?.settings
const stats = useMemo(() => {
const enabled = exercises.filter((e) => e.enabled)
const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
.sort((a, b) => a.ms - b.ms)[0]
return {
total: exercises.length,
active: enabled.length,
nextMs: next?.ms ?? Infinity
}
}, [exercises, ticks])
function openCreate(): void {
setEditing(null)
setEditorOpen(true)
}
function openEdit(ex: Exercise): void {
setEditing(ex)
setEditorOpen(true)
}
async function handleSave(draft: {
name: string
reps: number
icon: string
intervalMinutes: number
enabled: boolean
}): Promise<void> {
if (editing) {
await window.api.updateExercise(editing.id, draft)
} else {
await window.api.addExercise(draft)
}
setEditorOpen(false)
}
async function handleDelete(id: string): Promise<void> {
await window.api.deleteExercise(id)
}
async function togglePause(): Promise<void> {
if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
return (
<div className="p-8 overflow-y-auto h-full">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Дашборд</h1>
<p className="text-sm text-muted mt-1">
{stats.active} активных из {stats.total} упражнений
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={togglePause}>
{settings?.globalEnabled ? (
<>
<Pause size={16} /> Пауза
</>
) : (
<>
<Play size={16} /> Возобновить
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={16} /> Новое
</Button>
</div>
</div>
<div className="mb-6 rounded-2xl border border-border bg-surface px-5 py-4 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-accent/15 text-accent grid place-items-center">
<Clock size={20} />
</div>
<div className="flex-1">
<div className="text-xs text-muted uppercase tracking-wider">
Ближайшее напоминание
</div>
<div className="text-lg font-semibold mt-0.5">
{stats.nextMs === Infinity
? 'Нет активных упражнений'
: `через ${formatCountdown(stats.nextMs)}`}
</div>
</div>
{!settings?.globalEnabled && (
<div className="px-3 py-1.5 rounded-full bg-amber-500/15 text-amber-500 text-xs font-medium">
Напоминания на паузе
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
<ExerciseCard
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
onEdit={() => openEdit(ex)}
onDelete={() => handleDelete(ex.id)}
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
))}
</AnimatePresence>
</div>
{exercises.length === 0 && (
<div className="mt-10 text-center text-muted">
<p>Нет упражнений. Добавьте первое.</p>
</div>
)}
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format'
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)
return (
<div className="p-8 overflow-y-auto h-full">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Упражнения</h1>
<p className="text-sm text-muted mt-1">
Управляйте всеми упражнениями в одном месте
</p>
</div>
<Button
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={16} /> Добавить
</Button>
</div>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
{exercises.map((ex, i) => (
<div
key={ex.id}
className={[
'flex items-center gap-4 px-5 py-4',
i < exercises.length - 1 ? 'border-b border-border/60' : ''
].join(' ')}
>
<div
className={[
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
ex.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={ex.icon} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{ex.name}</div>
<div className="text-xs text-muted mt-0.5">
{ex.reps} раз · каждые {formatInterval(ex.intervalMinutes)}
</div>
</div>
<Switch
checked={ex.enabled}
onChange={(v) => window.api.toggleExercise(ex.id, v)}
/>
<button
onClick={() => {
setEditing(ex)
setEditorOpen(true)
}}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
aria-label="Редактировать"
>
<Pencil size={15} />
</button>
<button
onClick={() => window.api.deleteExercise(ex.id)}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
))}
{exercises.length === 0 && (
<div className="px-5 py-12 text-center text-muted">Список пуст</div>
)}
</div>
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={async (draft) => {
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)
setEditorOpen(false)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import {
Download,
Trash2,
RefreshCw,
CheckCircle2,
Hourglass
} from 'lucide-react'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import type { GameId, GameStatus } from '@shared/types'
export default function GamesPage(): JSX.Element {
const [games, setGames] = useState<GameStatus[]>([])
const [busy, setBusy] = useState<GameId | null>(null)
useEffect(() => {
void refresh()
const unsub = window.api.onGamesChanged(setGames)
return unsub
}, [])
async function refresh(): Promise<void> {
setGames(await window.api.listGames())
}
async function install(id: GameId): Promise<void> {
setBusy(id)
try {
await window.api.installGame(id)
} finally {
setBusy(null)
}
}
async function uninstall(id: GameId): Promise<void> {
setBusy(id)
try {
await window.api.uninstallGame(id)
} finally {
setBusy(null)
}
}
async function toggle(id: GameId, enabled: boolean): Promise<void> {
setBusy(id)
try {
await window.api.toggleGame(id, enabled)
} finally {
setBusy(null)
}
}
return (
<div className="p-8 overflow-y-auto h-full max-w-3xl">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Игры</h1>
<p className="text-sm text-muted mt-1">
Подключите свою игру приложение будет триггерить челленджи после матча
</p>
</div>
<Button variant="secondary" onClick={refresh}>
<RefreshCw size={16} /> Обновить
</Button>
</div>
<div className="space-y-4">
{games.map((g) => (
<GameRow
key={g.id}
game={g}
busy={busy === g.id}
onInstall={() => install(g.id)}
onUninstall={() => uninstall(g.id)}
onToggle={(v) => toggle(g.id, v)}
/>
))}
{games.length === 0 && (
<div className="text-muted text-sm">Ищем установленные игры</div>
)}
</div>
<DevPanel games={games} />
</div>
)
}
function GameRow({
game,
busy,
onInstall,
onUninstall,
onToggle
}: {
game: GameStatus
busy: boolean
onInstall: () => void
onUninstall: () => void
onToggle: (v: boolean) => void
}): JSX.Element {
return (
<div className="rounded-2xl border border-border bg-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-semibold text-lg">{game.name}</h3>
{game.installed ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium">
Установлена
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-medium">
Не найдена
</span>
)}
{game.integrationActive && game.launchOptionStatus === 'applied' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium inline-flex items-center gap-1">
<CheckCircle2 size={12} /> Готово к работе
</span>
)}
{game.integrationActive && game.launchOptionStatus === 'queued' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-500 font-medium inline-flex items-center gap-1">
<Hourglass size={12} /> Ждём закрытия Steam
</span>
)}
</div>
{game.installPath && (
<div className="text-xs text-muted mt-1 truncate font-mono">
{game.installPath}
</div>
)}
</div>
{game.installed && game.integrationActive && (
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
)}
</div>
{game.integrationActive && game.launchOptionStatus === 'queued' && (
<div className="mt-4 rounded-xl bg-amber-500/10 border border-amber-500/30 p-3 text-sm">
Steam сейчас запущен. Параметр запуска{' '}
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam ничего делать не
нужно.
</div>
)}
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
<div className="mt-4 rounded-xl bg-red-500/10 border border-red-500/30 p-3 text-sm">
В Steam нет ни одного залогиненного аккаунта (отсутствует папка{' '}
<code className="font-mono text-xs">userdata</code>). Запусти Steam один
раз, чтобы он создал конфиг потом снова нажми «Установить интеграцию».
</div>
)}
<div className="flex items-center gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy}>
<Download size={16} /> Установить интеграцию
</Button>
)}
{game.integrationActive && (
<Button variant="secondary" onClick={onUninstall} disabled={busy}>
<Trash2 size={16} /> Удалить интеграцию
</Button>
)}
{!game.installed && (
<div className="text-xs text-muted">
Установи игру в Steam и нажми «Обновить»
</div>
)}
</div>
</div>
)
}
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const [open, setOpen] = useState(false)
const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null
return (
<div className="mt-8 pt-6 border-t border-border/60">
<button
onClick={() => setOpen(!open)}
className="text-xs text-muted hover:text-text font-mono"
>
{open ? '▾' : '▸'} dev: симулировать конец матча
</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: 'KDA 8/3/12', stats: { kills: 8, deaths: 3, assists: 12 } }
] as { label: string; stats: Record<string, number> }[]).map((p) => (
<button
key={p.label}
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-border/60 text-text"
>
{p.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
if (!settings) return <div className="p-8">Загрузка</div>
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
}
return (
<div className="p-8 overflow-y-auto h-full max-w-2xl">
<h1 className="text-2xl font-bold mb-1">Настройки</h1>
<p className="text-sm text-muted mb-8">Настройте поведение приложения</p>
<Section title="Напоминания">
<SelectRow
label="Режим уведомления"
hint="Как должно выглядеть напоминание"
value={settings.notificationMode}
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
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 минут' }
]}
/>
</Section>
<Section title="Окно и трей">
<ToggleRow
label="Сворачивать в трей"
hint="При закрытии окна приложение остаётся работать в системном трее"
checked={settings.minimizeToTray}
onChange={(v) => patch({ minimizeToTray: v })}
/>
<ToggleRow
label="Запускать с Windows"
hint="Открывать приложение автоматически при входе в систему"
checked={settings.startWithWindows}
onChange={(v) => patch({ startWithWindows: v })}
/>
<ToggleRow
label="Запускать свёрнутым"
hint="При автозапуске открывать сразу в трее"
checked={settings.startMinimized}
onChange={(v) => patch({ startMinimized: v })}
disabled={!settings.startWithWindows}
/>
</Section>
<Section title="Внешний вид">
<SelectRow
label="Тема"
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: 'Как в системе' },
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
]}
/>
</Section>
</div>
)
}
function Section({
title,
children
}: {
title: string
children: React.ReactNode
}): JSX.Element {
return (
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-muted font-semibold mb-3">
{title}
</h2>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
{children}
</div>
</section>
)
}
function ToggleRow({
label,
hint,
checked,
onChange,
disabled
}: {
label: string
hint?: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}): JSX.Element {
return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
<div className="flex-1">
<div className="font-medium text-sm">{label}</div>
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
</div>
<Switch checked={checked} onChange={onChange} disabled={disabled} />
</div>
)
}
function SelectRow({
label,
hint,
value,
onChange,
options
}: {
label: string
hint?: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
}): JSX.Element {
return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
<div className="flex-1">
<div className="font-medium text-sm">{label}</div>
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { ReactNode, useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore'
function hexToRgbString(hex: string): string {
const cleaned = hex.replace('#', '').slice(0, 6).padEnd(6, '0')
const r = parseInt(cleaned.slice(0, 2), 16)
const g = parseInt(cleaned.slice(2, 4), 16)
const b = parseInt(cleaned.slice(4, 6), 16)
return `${r} ${g} ${b}`
}
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
window.api.getOsTheme().then(setOsTheme)
const unsub = window.api.onThemeChanged(setOsTheme)
return unsub
}, [])
useEffect(() => {
window.api.getAccentColor().then((color) => {
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
document.documentElement.style.setProperty(
'--accent-soft',
hexToRgbString(color)
)
})
const unsub = window.api.onAccentChanged((color) => {
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
})
return unsub
}, [])
useEffect(() => {
const pref = settings?.theme ?? 'system'
const effective = pref === 'system' ? osTheme : pref
if (effective === 'dark') document.documentElement.classList.add('dark')
else document.documentElement.classList.remove('dark')
}, [settings?.theme, osTheme])
return <>{children}</>
}

View File

@@ -0,0 +1,40 @@
import { create } from 'zustand'
import type { AppState, Tick } from '@shared/types'
type TickMap = Record<string, Tick>
type Store = {
state: AppState | null
ticks: TickMap
hydrated: boolean
hydrate: () => Promise<void>
setState: (s: AppState) => void
setTicks: (ticks: Tick[]) => void
}
export const useAppStore = create<Store>((set) => ({
state: null,
ticks: {},
hydrated: false,
hydrate: async () => {
const s = await window.api.getState()
set({ state: s, hydrated: true })
},
setState: (s) => set({ state: s }),
setTicks: (ticks) => {
const map: TickMap = {}
for (const t of ticks) map[t.exerciseId] = t
set({ ticks: map })
}
}))
export function subscribeToBackend(): () => void {
const store = useAppStore.getState()
store.hydrate()
const u1 = window.api.onStateChanged((s) => useAppStore.getState().setState(s))
const u2 = window.api.onTick((t) => useAppStore.getState().setTicks(t))
return () => {
u1()
u2()
}
}

View File

@@ -0,0 +1,93 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Default accent (Windows blue), overridden at runtime via systemPreferences.getAccentColor */
--accent: 91 141 239;
--accent-soft: 91 141 239;
color-scheme: light dark;
}
/* Light theme (default) */
:root {
--bg: 245 247 251;
--surface: 255 255 255;
--surface-elevated: 255 255 255;
--border: 226 230 240;
--text: 17 24 39;
--muted: 107 114 128;
}
/* Dark theme */
.dark {
--bg: 15 17 23;
--surface: 24 27 35;
--surface-elevated: 32 36 47;
--border: 45 50 64;
--text: 235 238 245;
--muted: 148 156 173;
}
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
background: rgb(var(--bg));
color: rgb(var(--text));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom titlebar drag region */
.titlebar-drag {
-webkit-app-region: drag;
app-region: drag;
}
.titlebar-nodrag {
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--muted) / 0.5);
}
::-webkit-scrollbar-track {
background: transparent;
}
/* Selection */
::selection {
background: rgb(var(--accent) / 0.35);
color: rgb(var(--text));
}
/* Reminder-window root: rounded corners & subtle border */
.reminder-shell {
border: 1px solid rgb(var(--border));
border-radius: 18px;
background: linear-gradient(
180deg,
rgb(var(--surface-elevated)) 0%,
rgb(var(--surface)) 100%
);
box-shadow: 0 24px 60px -20px rgb(0 0 0 / 0.55);
overflow: hidden;
height: 100%;
}