redesign(ui): полный реворк в стиле Apple iOS/macOS
Ветка для нового интерфейса; main с предыдущим Strava-дизайном
не тронут — можно вернуться через checkout main.
== Design system (foundation) ==
- Шрифты: Geist (sans, display) + Geist Mono (HUD числа) +
Instrument Serif (hero titles) — все через Google Fonts. Близки
к SF Pro / SF Mono.
- Палитра: Apple Human Interface Guidelines
* accent: 255 107 53 (Apple Fitness Move orange)
* success: 52 199 89 (systemGreen — для switch)
* destructive: 255 59 48 (systemRed)
* info: 0 122 255 (systemBlue)
* warning: 255 159 10
* Light bg: 242 242 247 (iOS systemGroupedBackground)
* Dark bg: true black 0 0 0 (OLED-friendly), elevation через
28/44/56 grey steps как в iOS Settings
- Утилиты: .vibrancy (macOS Big Sur sidebar), .hairline-b/-t (0.5px
iOS-стиль), .shadow-card (soft layered), .font-display/-serif/-mono-num
== UI primitives ==
- Button: filled / tinted / plain / destructive / success (iOS UIButton);
active:scale-[0.97] press feedback. Старые имена primary/secondary/
ghost/danger/victory маппятся через legacyMap для совместимости.
- Switch: настоящий iOS UISwitch 51x31, spring физика knob, success
цвет on.
- Modal: центрированный sheet с rounded-3xl (22px), backdrop blur,
spring scale-in. Header с font-display, X в circle.
- Card + Row + SectionHeader: iOS grouped list — белая поверхность,
hairline-b dividers между rows, last={true} убирает последний.
== App frame ==
- Sidebar: vibrancy (semi-transparent + backdrop-blur saturate 180%),
font-serif лого, tinted icon-plaques на каждом пункте (как в iOS
Settings), плавный hover. Drawer на mobile со spring slide.
- Titlebar: центрированный title, window controls без glow, hamburger
только на <md.
- App.tsx: AnimatePresence cross-fade между маршрутами.
== Pages ==
- Dashboard: hero с font-serif Large Title + датой. 3-card Hero panel
(Apple Fitness style) с tinted icon squares. ExerciseCard теперь с
progress-ring вокруг иконки + появляющейся "Готово" pill только при
due. Three-dot menu (iOS-style popover).
- Exercises: групированный список iOS, разделение Активные/Выключенные,
chevron-right на каждом row.
- Challenges: тот же групированный паттерн + warning banner если игр
нет, formula preview карточка в редакторе с big number в accent.
- Games: cards в новом стиле, статус-чипы pulse-dot для LIVE, dev
кнопки в pill-стиле.
- Settings: классические iOS Settings секции с ToggleRow и SelectRow
inside Card. UpdaterCard полностью переработан под Cell pattern.
== Reminder window ==
- iOS action sheet: большая иконка в accent-circle сверху, font-serif
название упражнения, гигантское моноширинное число reps. Кнопки
стопкой: primary Готово full-width, потом snooze + skip в grid.
Хоткеи Enter/Space/Esc сохранены.
- Match summary: tone-цветной icon plaque (success/destructive/accent),
ChallengeRow с pill-shaped check button.
== Анимации ==
- Spring физика везде где layout (Switch knob, Modal, Sidebar drawer,
карточки)
- active:scale-[0.97] на всех интерактивных элементах (iOS touch feel)
- Cross-fade между страницами через AnimatePresence
- Никаких glow / pulse-ring — apple style это сдержанность
Verified: typecheck OK, 23 tests pass, build 36.35 KB CSS (на 6 KB
меньше предыдущего HUD-стиля), 1.56 MB JS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Plus,
|
||||
Pause,
|
||||
Play,
|
||||
Timer,
|
||||
Flame,
|
||||
Activity,
|
||||
Gamepad2,
|
||||
Trophy
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Plus, Pause, Play, Flame, Activity } 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, formatInterval } from '../lib/format'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
@@ -25,7 +16,6 @@ export default function Dashboard(): JSX.Element {
|
||||
|
||||
const exercises = state?.exercises ?? []
|
||||
const settings = state?.settings
|
||||
const challenges = state?.challenges ?? []
|
||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -33,32 +23,24 @@ export default function Dashboard(): JSX.Element {
|
||||
const next = enabled
|
||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||
.sort((a, b) => a.ms - b.ms)[0]
|
||||
const totalReps = enabled.reduce((s, e) => s + e.reps, 0)
|
||||
const avgInterval =
|
||||
enabled.length > 0
|
||||
? Math.round(
|
||||
enabled.reduce((s, e) => s + e.intervalMinutes, 0) / enabled.length
|
||||
)
|
||||
: 0
|
||||
return {
|
||||
total: exercises.length,
|
||||
active: enabled.length,
|
||||
nextMs: next?.ms ?? Infinity,
|
||||
totalReps,
|
||||
avgInterval
|
||||
totalReps: enabled.reduce((s, e) => s + e.reps, 0)
|
||||
}
|
||||
}, [exercises, ticks])
|
||||
|
||||
const paused = !settings?.globalEnabled
|
||||
|
||||
function openCreate(): void {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(ex: Exercise): void {
|
||||
setEditing(ex)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave(draft: {
|
||||
name: string
|
||||
reps: number
|
||||
@@ -66,221 +48,197 @@ export default function Dashboard(): JSX.Element {
|
||||
intervalMinutes: number
|
||||
enabled: boolean
|
||||
}): Promise<void> {
|
||||
if (editing) {
|
||||
await window.api.updateExercise(editing.id, draft)
|
||||
} else {
|
||||
await window.api.addExercise(draft)
|
||||
}
|
||||
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 })
|
||||
}
|
||||
|
||||
const paused = !settings?.globalEnabled
|
||||
const today = new Date().toLocaleDateString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
|
||||
{/* Hero header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||
Тренировка дня
|
||||
<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-[13px] text-text/45 font-medium capitalize">
|
||||
{today}
|
||||
</div>
|
||||
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
|
||||
Сегодня
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="tinted" onClick={togglePause}>
|
||||
{!paused ? (
|
||||
<>
|
||||
<Pause size={14} strokeWidth={2.5} /> Пауза
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={14} strokeWidth={2.5} /> Старт
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Дашборд</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
{stats.active} активных из {stats.total} упражнений ·{' '}
|
||||
<span className="text-text font-mono-num font-semibold">
|
||||
{stats.totalReps}
|
||||
</span>{' '}
|
||||
повторов за цикл
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="secondary" onClick={togglePause}>
|
||||
{!paused ? (
|
||||
<>
|
||||
<Pause size={16} /> Пауза
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} /> Возобновить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} /> Новое
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD stat strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<HudStat
|
||||
icon={<Timer size={18} />}
|
||||
label="До следующего"
|
||||
value={
|
||||
stats.nextMs === Infinity
|
||||
? '—'
|
||||
: stats.nextMs <= 0
|
||||
? 'СЕЙЧАС'
|
||||
: formatCountdown(stats.nextMs)
|
||||
}
|
||||
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Activity size={18} />}
|
||||
label="Активных"
|
||||
value={`${stats.active}/${stats.total}`}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Flame size={18} />}
|
||||
label="Avg интервал"
|
||||
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Gamepad2 size={18} />}
|
||||
label="Трекинг матчей"
|
||||
value={gamesEnabled ? 'LIVE' : 'OFF'}
|
||||
accent={gamesEnabled}
|
||||
tone={gamesEnabled ? 'victory' : 'muted'}
|
||||
{/* Hero stat panel — Apple Fitness style */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
||||
<HeroStat
|
||||
tone="accent"
|
||||
label="Активных"
|
||||
value={`${stats.active}`}
|
||||
subvalue={`из ${stats.total}`}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone="info"
|
||||
label="До следующего"
|
||||
value={
|
||||
stats.nextMs === Infinity
|
||||
? '—'
|
||||
: stats.nextMs <= 0
|
||||
? 'Сейчас'
|
||||
: formatCountdown(stats.nextMs)
|
||||
}
|
||||
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={gamesEnabled ? 'success' : 'muted'}
|
||||
label="Трекинг матчей"
|
||||
value={gamesEnabled ? 'On' : 'Off'}
|
||||
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
|
||||
icon={
|
||||
<span
|
||||
className={[
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
gamesEnabled ? 'bg-white' : 'bg-text/30'
|
||||
].join(' ')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Paused banner */}
|
||||
{paused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-warning/15 text-warning grid place-items-center">
|
||||
<Pause size={16} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-semibold">
|
||||
Напоминания на паузе
|
||||
</div>
|
||||
<div className="text-[12px] text-text/55 mt-0.5">
|
||||
Возобнови, чтобы продолжить отсчёт
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="filled" size="sm" onClick={togglePause}>
|
||||
<Play size={13} strokeWidth={2.5} /> Старт
|
||||
</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) => (
|
||||
<ExerciseCard
|
||||
key={ex.id}
|
||||
exercise={ex}
|
||||
tick={ticks[ex.id]}
|
||||
onEdit={() => openEdit(ex)}
|
||||
onDelete={() => window.api.deleteExercise(ex.id)}
|
||||
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
|
||||
onMarkDone={() => window.api.markDone(ex.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{exercises.length === 0 && (
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
|
||||
<Plus size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="font-display text-[20px] font-semibold">
|
||||
Программа пуста
|
||||
</div>
|
||||
<p className="text-[14px] text-text/55 mt-1">
|
||||
Добавь первое упражнение, чтобы начать
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExerciseEditor
|
||||
open={editorOpen}
|
||||
exercise={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Paused banner */}
|
||||
{paused && (
|
||||
<div className="mb-6 rounded-2xl border border-xp/30 bg-xp/10 px-5 py-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-xp/20 text-xp grid place-items-center">
|
||||
<Pause size={16} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">Тренировка на паузе</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
Напоминания не сработают, пока не возобновишь
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="victory" size="sm" onClick={togglePause}>
|
||||
<Play size={14} /> GO
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Challenges shortcut */}
|
||||
{challenges.length > 0 && (
|
||||
<div className="mb-6 rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm px-5 py-4 flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent-2/15 text-accent-2 grid place-items-center">
|
||||
<Trophy size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-muted uppercase tracking-[0.18em] font-semibold">
|
||||
Активные челленджи
|
||||
</div>
|
||||
<div className="text-base font-display font-semibold mt-0.5">
|
||||
{challenges.length} правил привязано к матчам
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-muted">
|
||||
См. вкладку <span className="text-accent font-semibold">Челленджи</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exercise grid */}
|
||||
<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-12 text-center">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-4">
|
||||
<Plus size={28} />
|
||||
</div>
|
||||
<div className="font-display text-xl font-semibold uppercase tracking-wider mb-1">
|
||||
Старт пуст
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Добавь первое упражнение — и поехали
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExerciseEditor
|
||||
open={editorOpen}
|
||||
exercise={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HudStat({
|
||||
icon,
|
||||
function HeroStat({
|
||||
tone,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
tone = 'accent'
|
||||
subvalue,
|
||||
icon
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
tone: 'accent' | 'info' | 'success' | 'muted'
|
||||
label: string
|
||||
value: string
|
||||
accent?: boolean
|
||||
tone?: 'accent' | 'victory' | 'muted'
|
||||
subvalue?: string
|
||||
icon?: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const toneClasses =
|
||||
tone === 'victory'
|
||||
? 'text-victory bg-victory/15'
|
||||
: tone === 'muted'
|
||||
? 'text-muted bg-surface-elevated'
|
||||
: 'text-accent bg-accent/15'
|
||||
const toneBg =
|
||||
tone === 'accent'
|
||||
? 'bg-accent'
|
||||
: tone === 'info'
|
||||
? 'bg-info'
|
||||
: tone === 'success'
|
||||
? 'bg-success'
|
||||
: 'bg-text/40'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'relative rounded-2xl border bg-surface/60 backdrop-blur-sm px-4 py-3 overflow-hidden',
|
||||
accent ? 'border-accent/40 shadow-glow' : 'border-border/70'
|
||||
].join(' ')}
|
||||
>
|
||||
{accent && (
|
||||
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-accent/20 blur-2xl pointer-events-none" />
|
||||
)}
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className={[
|
||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||
toneClasses
|
||||
'w-6 h-6 rounded-md grid place-items-center text-white',
|
||||
toneBg
|
||||
].join(' ')}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-display font-bold text-xl tracking-wide truncate">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-text/55 font-medium">{label}</div>
|
||||
</div>
|
||||
<div className="font-display text-[30px] font-semibold tracking-tight leading-none">
|
||||
{value}
|
||||
</div>
|
||||
{subvalue && (
|
||||
<div className="text-[12px] text-text/45 mt-1.5">{subvalue}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user