6 Commits

Author SHA1 Message Date
AnRil
ee2dc19daa release(v0.3.4): шрифты — Plus Jakarta Sans + Bricolage Grotesque
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
Manrope воспринимался слишком строгим и корпоративным. Замена даёт
больше характера и тёплый "попсовый" feel:

- Body/UI: Manrope → Plus Jakarta Sans (мягкие округлые формы 'a' 'g',
  очень распространён в современных трендовых приложениях)
- Display/hero: Fraunces → Bricolage Grotesque (variable шрифт с opsz
  axis: 24 для нормальных заголовков, 96 для hero — гротеск с
  характерными слегка сжатыми формами и большим контрастом штрихов)
- Mono: JetBrains Mono без изменений

Все hero-заголовки пробампаны до 34→40px и font-bold (700), Bricolage
лучше всего смотрится в полужирном/жирном. Sidebar логотип «Laude»
тоже font-bold.

Также:
- body line-height: 1.45 → 1.5 для лучшей читаемости
- Reminder exercise name: 28→30, semibold→bold
- Match summary title: 24→26, semibold→bold
- Sidebar slogan: 12→13/medium, контраст 45→55

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:36:59 +07:00
AnRil
9b488164e0 release(v0.3.3): акцентная типография — все надписи крупнее и контрастнее
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
Жалоба: вторичные подписи (Активных / До следующего / Трекинг матчей /
Возобнови чтобы продолжить отсчёт) выглядели мелко и плохо читались.

Сделан sweep по всему UI:
- Базовая шкала secondary text: 12px → 13-14px
- Контрастность подписей: text-text/45 → text-text/65 (или /75 для лейблов)
- font-medium → font-semibold для метаданных карточек

Dashboard:
- Дата: 13/font-medium → 14/font-semibold
- HeroStat label: 12/medium/55 → 14/semibold/75 (вот эти "Активных" и пр.)
- HeroStat value: 26/semibold → 28/bold
- HeroStat subvalue: 12/45 → 13/60/medium
- HeroStat icon plaque: 24px → 28px
- Paused banner title: 14 → 16, hint: 12 → 14/70
- Иконка баннера 36→40px

Settings:
- ToggleRow/SelectRow label: medium → semibold
- Hint: 12/55 → 13/65/medium
- "Конфигурация": 13/45/medium → 14/65/semibold

Exercises/Challenges (row + page):
- Row title: 15/medium → 16/semibold
- Row subtitle: 13/55 → 14/65/medium
- Стат-метрики bold
- Empty state: 14/55 → 15/65/medium
- Warning banner: 13/80 → 14/85/medium + иконка крупнее

Games:
- Game title: 17/semibold → 18/bold
- Install path subtitle: 12/45 → 13/55/medium
- Queue/error banners: 13/80 → 14/85/medium + крупнее иконки и code

ExerciseCard:
- Title: 17/semibold → 18/bold
- Reps meta: 13/55 → 14/65/medium
- Countdown label "Через/Сейчас": 11/45 → 12/60/semibold
- Countdown value: 22/semibold → 24/bold
- "Готово" CTA: 14/semibold → 15/bold, h-10 → h-11

ReminderApp:
- "Время тренировки" label: 12/45 → 13 + accent цвет + bold
- "Раз" подпись: 14/55 → 15/65/semibold
- "Следующее через": 12/45 → 13/65/medium
- Match summary header: 11/45 → 12/65/semibold
- Match summary subtitle: 12/45 → 13/65/medium
- Match summary total: 12/55 → 13/65/medium + 14 → 16 для числа
- ChallengeRow title: 14/medium → 15/semibold
- ChallengeRow subtitle: 12/55 → 13/65/medium
- CTA Готово: 15/semibold → 16/bold

UpdaterCard:
- Cell title medium → semibold
- Cell subtitle: 12/55 → 13/65/medium
- Иконка ячеек 36→40px
- Progress download title medium → semibold + 18px процент

Card SectionHeader:
- 12/medium/45 → 13/semibold/60

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:00:52 +07:00
AnRil
aa60acb164 release(v0.3.2): новые шрифты, иконки сайдбара, light по умолчанию
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
== Шрифты ==
- Sans/display: Geist → Manrope (мягче, дружелюбнее, ближе к SF Rounded)
- Serif (hero titles): Instrument Serif → Fraunces с opsz axis 144
- Mono: Geist Mono → JetBrains Mono с ss02/ss19/zero features

== Размеры (iOS HIG calibration) ==
- Hero h1: 40-44px → 32-36px (ближе к настоящему iOS Large Title 34pt)
- Reminder name: 32 → 28; reps counter: 64 → 56
- Match summary title: 22 → 24
- Dashboard stat value: 30 → 26
- Body line-height/letter-spacing подкручены под Manrope

== Иконки сайдбара ==
- LayoutDashboard → Sun (Сегодня — утренняя энергия)
- ListChecks → Dumbbell (Упражнения — спорт прямо)
- Gamepad2 → Joystick (Игры — более игровая иконка)
- Target → Flame (Челленджи — интенсивность)
- Settings → Settings2 (немного объёмнее)
- Размер плашки 28→32px, иконки 15→17px

== Light theme ==
- DEFAULT_SETTINGS.theme: 'system' → 'light' (новые установки сразу
  получают светлую тему)
- Light bg прогрет: 242,242,247 → 245,245,249
- surface-2 чуть темнее для лучшей сепарации полей ввода
- text не pure black а 17,17,19 (легче глазам)
- shadow-card получила тёплый slate-tinge

Существующие пользователи с theme='system' продолжат следовать ОС.
Для принудительного переключения — Settings → Тема → Светлая.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:49:46 +07:00
AnRil
660b6d57d8 chore(release): v0.3.1 — Apple/iOS дизайн
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 в стиле Apple iOS/macOS:
- Geist + Instrument Serif шрифты вместо Rajdhani
- Apple HIG палитра (systemOrange, systemGreen, systemRed, true black dark)
- macOS vibrancy sidebar, iOS grouped lists, UISwitch, action sheets
- Spring анимации, active:scale press feedback

Установщик ведёт себя как install-or-update — обновляет существующую
0.2.x/0.3.0 копию с сохранением настроек.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:17:27 +07:00
AnRil
c5a29214d2 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>
2026-05-17 14:17:35 +07:00
AnRil
6ffa100645 feat(release): add upload-release-assets.ps1 helper
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Скрипт догружает уже собранные NSIS-артефакты (.exe, .blockmap,
latest.yml) в существующий Gitea release. Создаёт release если его
нет, удаляет одноимённые активы перед перезаписью.

Используется когда:
- release.ps1 успел запушить тег, но упал на загрузке
- релиз делался руками без артефактов
- нужно перезалить артефакты после пересборки

Большие файлы (>50MB) грузятся через curl.exe, потому что
Invoke-RestMethod в PS 5.1 разрывает соединение на multipart upload
80+MB. Прогресс печатается отдельно для каждого файла.

Использован для загрузки v0.3.0 артефактов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:44:42 +07:00
23 changed files with 1705 additions and 1621 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist
*.log *.log
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.claude/

View File

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

View File

@@ -0,0 +1,161 @@
<#
.SYNOPSIS
Upload pre-built NSIS artifacts to an existing Gitea release.
.DESCRIPTION
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to
push but failed on upload, or release was created manually without assets).
If a release for the tag does not exist yet, it is created. If it exists,
same-name assets are replaced.
.PARAMETER Tag
Version tag, e.g. v0.3.0. Defaults to v<package.json version>.
.EXAMPLE
pwsh scripts/upload-release-assets.ps1
pwsh scripts/upload-release-assets.ps1 -Tag v0.3.0
#>
param(
[string]$Tag
)
$ErrorActionPreference = 'Stop'
$repoOwner = 'AnRil'
$repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
$apiBase = "https://$giteaHost/api/v1"
if (-not $env:GITEA_TOKEN) {
Write-Error "GITEA_TOKEN not set. Set it via [Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<value>', 'User') and open a new PowerShell session."
exit 1
}
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root
if (-not $Tag) {
$version = (Get-Content package.json | ConvertFrom-Json).version
$Tag = "v$version"
}
$version = $Tag.TrimStart('v')
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
$blockmap = "$installer.blockmap"
$manifest = Join-Path 'release' 'latest.yml'
foreach ($f in @($installer, $blockmap, $manifest)) {
if (-not (Test-Path $f)) {
Write-Error "Artifact not found: $f. Build first with: npm run dist"
exit 1
}
}
$headers = @{
Authorization = "token $env:GITEA_TOKEN"
Accept = 'application/json'
}
# --- Find or create release ----------------------------------------------
Write-Host "Looking for existing release $Tag..." -ForegroundColor Cyan
$release = $null
try {
$release = Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases/tags/$Tag" `
-Method Get `
-Headers $headers
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
$prev = $null
try {
$prevTagOutput = & git describe --tags --abbrev=0 "$Tag^" 2>$null
if ($LASTEXITCODE -eq 0 -and $prevTagOutput) {
$prev = $prevTagOutput.Trim()
}
} catch {
$prev = $null
}
if ($prev) {
$log = (& git log --pretty=format:"- %s" "$prev..$Tag") -join "`n"
} else {
# No prior tag — list last 10 commits up to this tag.
$log = (& git log --pretty=format:"- %s" -n 10 "$Tag") -join "`n"
}
$body = "### Changes`n`n$log`n`n---`n`nInstaller below: run it; if app is already installed, it updates in place and keeps your settings."
$payload = @{
tag_name = $Tag
name = "Exercise Reminder $Tag"
body = $body
draft = $false
prerelease = $false
} | ConvertTo-Json -Depth 5
$release = Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases" `
-Method Post `
-Headers $headers `
-Body $payload `
-ContentType 'application/json'
Write-Host " Created release id=$($release.id)" -ForegroundColor Green
} else {
throw
}
}
# --- Delete existing assets with same names (to allow re-upload) ---------
$existing = Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets" `
-Method Get `
-Headers $headers
foreach ($asset in @($installer, $blockmap, $manifest)) {
$name = Split-Path $asset -Leaf
$found = $existing | Where-Object { $_.name -eq $name }
if ($found) {
Write-Host "Removing existing asset $name (id=$($found.id))..." -ForegroundColor Yellow
Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets/$($found.id)" `
-Method Delete `
-Headers $headers | Out-Null
}
}
# --- Upload assets -------------------------------------------------------
# Use curl.exe (bundled with Win10+) because Invoke-RestMethod in PS 5.1
# chokes on large multipart uploads (>50MB) over slower connections.
$curlCmd = Get-Command curl.exe -ErrorAction SilentlyContinue
if ($curlCmd) {
$curl = $curlCmd.Source
} else {
$curl = "$env:SystemRoot\System32\curl.exe"
if (-not (Test-Path $curl)) {
Write-Error "curl.exe not found. Install via 'winget install curl' or add to PATH."
exit 1
}
}
foreach ($asset in @($installer, $blockmap, $manifest)) {
$name = Split-Path $asset -Leaf
$size = (Get-Item $asset).Length
Write-Host ("Uploading {0} ({1:N1} MB)..." -f $name, ($size / 1MB)) -ForegroundColor Cyan
$uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))"
# -f: fail on HTTP errors; -s -S: silent but show errors; --data-binary @file
& $curl `
--fail-with-body `
--silent --show-error `
-H "Authorization: token $env:GITEA_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$asset" `
$uri
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload failed for $name (curl exit $LASTEXITCODE)"
exit 1
}
}
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$Tag"
Write-Host ""
Write-Host "Release assets uploaded" -ForegroundColor Green
Write-Host " $releaseUrl"

View File

@@ -7,7 +7,7 @@
<title>Exercise Reminder</title> <title>Exercise Reminder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Rajdhani:wght@500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { HashRouter, Route, Routes, useLocation } from 'react-router-dom' import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar' import { Titlebar } from './components/Titlebar'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
@@ -32,9 +33,9 @@ export default function App(): JSX.Element {
/> />
<main className="flex-1 overflow-hidden min-w-0"> <main className="flex-1 overflow-hidden min-w-0">
{hydrated ? ( {hydrated ? (
<RoutesWithCloseOnNav onClose={() => setMobileNavOpen(false)} /> <RoutedPages onNav={() => setMobileNavOpen(false)} />
) : ( ) : (
<div className="p-8 text-muted">Загрузка</div> <div className="p-8 text-text/45">Загрузка</div>
)} )}
</main> </main>
</div> </div>
@@ -43,20 +44,31 @@ export default function App(): JSX.Element {
) )
} }
// Close mobile drawer whenever the route changes. function RoutedPages({ onNav }: { onNav: () => void }): JSX.Element {
function RoutesWithCloseOnNav({ onClose }: { onClose: () => void }): JSX.Element {
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
onClose() onNav()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]) }, [location.pathname])
return ( return (
<Routes> <AnimatePresence mode="wait" initial={false}>
<Route path="/" element={<Dashboard />} /> <motion.div
<Route path="/exercises" element={<Exercises />} /> key={location.pathname}
<Route path="/games" element={<GamesPage />} /> initial={{ opacity: 0, y: 6 }}
<Route path="/challenges" element={<ChallengesPage />} /> animate={{ opacity: 1, y: 0 }}
<Route path="/settings" element={<SettingsPage />} /> exit={{ opacity: 0, y: -4 }}
</Routes> transition={{ duration: 0.18, ease: 'easeOut' }}
className="h-full"
>
<Routes location={location}>
<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>
</motion.div>
</AnimatePresence>
) )
} }

View File

@@ -1,16 +1,12 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
Check, import type {
Clock, Exercise,
X, MatchSummary,
Trophy, Settings,
Skull, ChallengeResult
Gamepad2, } from '@shared/types'
Flame,
Zap
} from 'lucide-react'
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
import { Icon } from './lib/icon' import { Icon } from './lib/icon'
import { formatInterval } from './lib/format' import { formatInterval } from './lib/format'
@@ -46,7 +42,7 @@ export default function ReminderApp(): JSX.Element {
} }
}, []) }, [])
// Keyboard shortcuts on reminder window // Keyboard shortcuts (iOS-like Enter to confirm)
useEffect(() => { useEffect(() => {
if (mode.kind !== 'exercise') return if (mode.kind !== 'exercise') return
const ex = mode.exercise const ex = mode.exercise
@@ -120,101 +116,71 @@ function ExerciseReminder({
} }
return ( return (
<div className="reminder-shell flex flex-col h-full hud-scanlines"> <div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-9 px-3 flex items-center justify-between"> <div className="titlebar-drag h-8 px-2 flex items-center justify-end">
<div className="text-[10px] uppercase tracking-[0.2em] text-accent font-display font-semibold inline-flex items-center gap-1.5 px-2">
<Flame size={11} /> Время тренировки
</div>
<button <button
onClick={onClose} onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted" 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="Закрыть"
> >
<X size={13} /> <X size={13} strokeWidth={2.5} />
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
<div className="flex-1 flex flex-col items-center justify-center px-8 text-center">
<motion.div <motion.div
initial={{ scale: 0.6, opacity: 0, rotate: -8 }} initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }} animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 16 }} transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="relative mb-6" className="relative mb-6"
> >
{/* Outer rotating ring */} <div className="w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-[0_8px_30px_-8px_rgb(var(--accent)/0.5)]">
<motion.div <Icon name={exercise.icon} size={44} strokeWidth={2} />
className="absolute -inset-3 rounded-full"
style={{
background:
'conic-gradient(from 0deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 50%, rgb(var(--accent)) 100%)'
}}
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
/>
<div className="absolute -inset-3 rounded-full bg-surface m-[3px]" />
<div className="absolute inset-0 rounded-full bg-accent/40 blur-2xl animate-pulse-ring" />
<div className="relative w-28 h-28 rounded-full bg-gradient-brand text-white grid place-items-center shadow-glow-lg">
<Icon name={exercise.icon} size={48} />
</div> </div>
</motion.div> </motion.div>
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold"> <div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
Двигайся Время тренировки
</div> </div>
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide"> <h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
{exercise.name} {exercise.name}
</h1> </h1>
{/* HUD reps counter */} <div className="inline-flex items-baseline gap-2 font-mono-num">
<div className="inline-flex items-baseline gap-2 px-5 py-2 rounded-2xl border border-accent/30 bg-accent/10 shadow-glow"> <span className="text-[56px] font-semibold tracking-tight text-text leading-none">
<Zap size={16} className="text-xp" />
<span className="font-mono-num font-bold text-5xl text-gradient-brand leading-none">
{exercise.reps} {exercise.reps}
</span> </span>
<span className="text-xs font-display font-semibold text-muted uppercase tracking-widest"> <span className="text-[15px] text-text/65 font-semibold">раз</span>
REPS
</span>
</div> </div>
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
<Clock size={10} /> <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)} Следующее через {formatInterval(exercise.intervalMinutes)}
</div> </div>
</div> </div>
<div className="px-6 pb-6 grid grid-cols-3 gap-2"> {/* iOS action sheet — buttons stacked vertically, equal width */}
<button <div className="px-4 pb-4 space-y-2">
onClick={skip}
title="Esc"
className="group h-12 rounded-xl bg-surface-elevated hover:bg-defeat/15 hover:text-defeat text-muted text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 transition-colors"
>
<span className="inline-flex items-center gap-1.5">
<X size={14} /> Пропустить
</span>
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
ESC
</span>
</button>
<button
onClick={snooze}
title="Space"
className="group h-12 rounded-xl bg-surface-elevated hover:bg-surface-elevated/80 text-text text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 border border-border/60 hover:border-accent/40 transition-colors"
>
<span className="inline-flex items-center gap-1.5">
<Clock size={14} /> Отложить {snoozeMinutes}м
</span>
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
SPACE
</span>
</button>
<button <button
onClick={done} onClick={done}
title="Enter" 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"
className="group h-12 rounded-xl bg-gradient-victory text-white text-sm font-bold uppercase tracking-wide inline-flex flex-col items-center justify-center gap-0.5 shadow-glow-victory hover:brightness-110 transition-all"
> >
<span className="inline-flex items-center gap-1.5"> <Check size={17} strokeWidth={2.5} /> Готово
<Check size={16} /> Сделал
</span>
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
</button> </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} мин
</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"
>
Пропустить
</button>
</div>
</div> </div>
</div> </div>
) )
@@ -239,71 +205,59 @@ function MatchSummaryView({
const won = summary.won === true const won = summary.won === true
const lost = summary.won === false const lost = summary.won === false
const heroGradient = won
? 'bg-gradient-victory'
: lost
? 'bg-gradient-defeat'
: 'bg-gradient-brand'
return ( return (
<div className="reminder-shell flex flex-col h-full hud-scanlines"> <div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-9 px-3 flex items-center justify-between"> <div className="titlebar-drag h-9 px-2 flex items-center justify-between">
<div className="text-[10px] uppercase tracking-[0.2em] text-muted font-display font-semibold inline-flex items-center gap-1.5 px-2"> <div className="text-[12px] text-text/65 font-semibold inline-flex items-center gap-1.5 px-2">
<Gamepad2 size={12} /> {summary.gameName} <Gamepad2 size={12} strokeWidth={2.4} /> {summary.gameName}
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted" 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="Закрыть"
> >
<X size={13} /> <X size={13} strokeWidth={2.5} />
</button> </button>
</div> </div>
<div className="px-6 pt-2 pb-4 text-center"> <div className="px-5 pt-1 pb-4 text-center">
<motion.div <motion.div
initial={{ scale: 0.7, opacity: 0 }} initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }} transition={{ type: 'spring', stiffness: 280, damping: 22 }}
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3" className={[
'inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-3 text-white',
won ? 'bg-success' : lost ? 'bg-destructive' : 'bg-accent'
].join(' ')}
> >
<div className={`absolute inset-0 rounded-2xl ${heroGradient} blur-md opacity-70`} /> {won ? (
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}> <Trophy size={26} strokeWidth={2} />
{won ? ( ) : lost ? (
<Trophy size={30} /> <Frown size={26} strokeWidth={2} />
) : lost ? ( ) : (
<Skull size={30} /> <Gamepad2 size={26} strokeWidth={2} />
) : ( )}
<Gamepad2 size={30} />
)}
</div>
</motion.div> </motion.div>
<h1 className="font-display font-bold text-xl uppercase tracking-wide"> <h1 className="font-serif text-[26px] tracking-tight font-bold">
{won {won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
? 'Победа · тренировка заработана'
: lost
? 'Проигрыш · но тело не сдаётся'
: 'Матч завершён'}
</h1> </h1>
<p className="text-[11px] text-muted mt-1.5"> <p className="text-[13px] text-text/65 mt-1.5 font-medium">
<span className="font-mono-num font-semibold text-text"> <span className="font-mono-num font-bold text-text">
{Math.floor(summary.durationMs / 60_000)} {Math.floor(summary.durationMs / 60_000)}
</span>{' '} </span>{' '}
мин · {summary.results.length}{' '} мин · {summary.results.length} челлендж
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '} {summary.results.length === 1 ? '' : 'а'} ·{' '}
{allDone ? ( {allDone ? (
<span className="text-victory font-semibold uppercase tracking-wider"> <span className="text-success font-bold">всё готово</span>
готово
</span>
) : ( ) : (
<span className="text-accent font-bold font-mono-num"> <span className="text-accent font-mono-num font-bold">
{remainingReps} осталось {remainingReps} осталось
</span> </span>
)} )}
</p> </p>
</div> </div>
<div className="flex-1 overflow-y-auto px-4 space-y-2 pb-2"> <div className="flex-1 overflow-y-auto px-3 space-y-1.5 pb-2">
{summary.results.map((r) => ( {summary.results.map((r) => (
<ChallengeRow <ChallengeRow
key={r.challengeId} key={r.challengeId}
@@ -314,10 +268,10 @@ function MatchSummaryView({
))} ))}
</div> </div>
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40"> <div className="px-4 pb-4 pt-3 flex items-center gap-3">
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold"> <div className="flex-1 text-[13px] text-text/65 font-medium">
Всего ·{' '} Всего ·{' '}
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal"> <span className="text-text font-mono-num font-bold text-[16px]">
{totalReps} {totalReps}
</span>{' '} </span>{' '}
повторов повторов
@@ -325,13 +279,13 @@ function MatchSummaryView({
<button <button
onClick={onClose} onClick={onClose}
className={[ className={[
'h-11 px-5 rounded-xl text-white text-sm font-bold uppercase tracking-wider inline-flex items-center gap-1.5 transition-all hover:brightness-110', 'h-11 px-5 rounded-2xl text-white text-[14px] font-semibold inline-flex items-center gap-1.5 active:scale-[0.98] transition-all',
allDone ? 'bg-gradient-victory shadow-glow-victory' : 'bg-gradient-brand shadow-glow' allDone ? 'bg-success' : 'bg-accent'
].join(' ')} ].join(' ')}
> >
{allDone ? ( {allDone ? (
<> <>
<Check size={16} /> Готово <Check size={14} strokeWidth={2.5} /> Закрыть
</> </>
) : ( ) : (
'Позже' 'Позже'
@@ -357,41 +311,38 @@ function ChallengeRow({
initial={{ opacity: 0, x: -8 }} initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
className={[ className={[
'flex items-center gap-3 rounded-xl p-3 border transition-colors', 'flex items-center gap-3 rounded-2xl p-3 transition-colors',
done done ? 'bg-success/10' : 'bg-surface-2'
? 'border-victory/40 bg-victory/10'
: 'border-border/70 bg-surface-elevated/60'
].join(' ')} ].join(' ')}
> >
<div <div
className={[ className={[
'w-11 h-11 rounded-lg grid place-items-center shrink-0', 'w-10 h-10 rounded-xl grid place-items-center shrink-0',
done ? 'bg-victory/20 text-victory' : 'bg-accent/15 text-accent' done ? 'bg-success text-white' : 'bg-accent text-white'
].join(' ')} ].join(' ')}
> >
<Icon name={result.icon} size={22} /> <Icon name={result.icon} size={19} strokeWidth={2.2} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div
className={[ className={[
'font-display font-semibold tracking-wide truncate', 'text-[15px] font-semibold truncate',
done ? 'line-through opacity-60' : '' done ? 'line-through opacity-55' : ''
].join(' ')} ].join(' ')}
> >
{result.exerciseName} {result.exerciseName}
</div> </div>
<div className="text-[11px] text-muted mt-0.5"> <div className="text-[13px] text-text/65 mt-0.5 font-medium">
<span className="font-mono-num font-bold text-text"> <span className="font-mono-num font-bold text-text">
{result.statValue} {result.statValue}
</span>{' '} </span>{' '}
{result.statLabel} {' '} {result.statLabel} <span>{result.name}</span>
<span className="text-accent">{result.name}</span>
</div> </div>
</div> </div>
<div <div
className={[ className={[
'font-mono-num text-2xl font-bold tabular-nums', 'font-mono-num text-[20px] font-semibold tracking-tight',
done ? 'text-victory' : 'text-gradient-brand' done ? 'text-success' : 'text-accent'
].join(' ')} ].join(' ')}
> >
{result.reps} {result.reps}
@@ -400,14 +351,14 @@ function ChallengeRow({
onClick={onMarkDone} onClick={onMarkDone}
disabled={done} disabled={done}
className={[ className={[
'h-9 w-9 grid place-items-center rounded-lg transition-colors', 'h-9 w-9 grid place-items-center rounded-full transition-all',
done done
? 'bg-victory text-white cursor-default' ? 'bg-success text-white cursor-default'
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow' : 'bg-accent text-white active:scale-90'
].join(' ')} ].join(' ')}
aria-label="Готово" aria-label="Готово"
> >
<Check size={16} /> <Check size={15} strokeWidth={2.5} />
</button> </button>
</motion.div> </motion.div>
) )

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, Pencil, Trash2, Zap } from 'lucide-react' import { Check, MoreHorizontal } from 'lucide-react'
import { useState } from 'react'
import type { Exercise, Tick } from '@shared/types' import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format' import { formatCountdown, formatInterval } from '../lib/format'
@@ -14,6 +15,11 @@ type Props = {
onMarkDone: () => void onMarkDone: () => void
} }
/**
* iOS-flavoured exercise card. White surface, soft shadow, big readable
* countdown. A subtle ring around the icon shows interval progress —
* Apple Fitness ring spirit but minimalist.
*/
export function ExerciseCard({ export function ExerciseCard({
exercise, exercise,
tick, tick,
@@ -27,8 +33,9 @@ export function ExerciseCard({
const remaining = Math.max(0, Math.min(total, ms)) const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0 const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled const isDue = ms <= 0 && exercise.enabled
const [menuOpen, setMenuOpen] = useState(false)
// SVG cooldown ring math // Ring math
const R = 22 const R = 22
const C = 2 * Math.PI * R const C = 2 * Math.PI * R
const dashOffset = C * (1 - elapsedPct) const dashOffset = C * (1 - elapsedPct)
@@ -36,145 +43,140 @@ export function ExerciseCard({
return ( return (
<motion.div <motion.div
layout layout
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.97 }}
whileHover={{ y: -2 }} transition={{ type: 'spring', stiffness: 380, damping: 30 }}
transition={{ type: 'spring', stiffness: 280, damping: 24 }} className="relative bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"
className={[
'group relative rounded-2xl border bg-surface/80 backdrop-blur-sm p-5 flex flex-col gap-4',
'transition-shadow',
isDue
? 'neon-border hud-pulse border-transparent'
: 'border-border/70 hover:border-accent/40 hover:shadow-soft'
].join(' ')}
> >
{/* Glow corner accent */} <div className="flex items-start gap-4">
<div {/* Icon + progress ring */}
className={[ <div className="relative w-14 h-14 shrink-0">
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity', <svg
exercise.enabled className="absolute inset-0 -rotate-90"
? 'bg-accent/15 opacity-100' viewBox="0 0 56 56"
: 'bg-muted/10 opacity-50' width="56"
].join(' ')} height="56"
/> >
<circle
<div className="relative flex items-start justify-between gap-3"> cx="28"
<div className="flex items-center gap-3 min-w-0"> cy="28"
{/* Hex-like rounded icon plaque with cooldown ring */} r={R}
<div className="relative w-14 h-14 shrink-0"> fill="none"
<svg strokeWidth="2.5"
className="absolute inset-0 -rotate-90" className="stroke-hairline/15 dark:stroke-hairline/30"
viewBox="0 0 56 56" />
width="56" {exercise.enabled && (
height="56"
>
<defs>
<linearGradient id="cooldownGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="rgb(var(--accent))" />
<stop offset="100%" stopColor="rgb(var(--accent-2))" />
</linearGradient>
</defs>
<circle <circle
className="cooldown-track"
cx="28" cx="28"
cy="28" cy="28"
r={R} r={R}
fill="none" fill="none"
strokeWidth="3" strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={dashOffset}
className={
isDue ? 'stroke-accent' : 'stroke-accent/85'
}
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
/> />
{exercise.enabled && ( )}
<circle </svg>
className="cooldown-fill" <div
cx="28"
cy="28"
r={R}
fill="none"
strokeWidth="3"
strokeDasharray={C}
strokeDashoffset={dashOffset}
/>
)}
</svg>
<div
className={[
'absolute inset-[7px] rounded-full grid place-items-center transition-colors',
exercise.enabled
? 'bg-accent/15 text-accent'
: 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={exercise.icon} size={20} />
</div>
</div>
<div className="min-w-0">
<div className="font-semibold leading-tight truncate font-display text-lg tracking-wide">
{exercise.name}
</div>
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5">
<Zap size={11} className="text-xp" />
<span className="font-mono-num font-semibold text-text">
{exercise.reps}
</span>
<span>повторов · каждые {formatInterval(exercise.intervalMinutes)}</span>
</div>
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label="Включить/выключить"
/>
</div>
<div className="relative">
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
Через
</span>
<span
className={[ className={[
'text-sm font-mono-num font-bold', 'absolute inset-[8px] rounded-full grid place-items-center',
isDue ? 'text-accent' : 'text-text' exercise.enabled
? 'bg-accent/10 text-accent'
: 'bg-surface-2 text-text/40'
].join(' ')} ].join(' ')}
> >
{exercise.enabled ? formatCountdown(ms) : 'пауза'} <Icon name={exercise.icon} size={20} />
</span> </div>
</div> </div>
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
<motion.div <div className="flex-1 min-w-0">
className={[ <div className="flex items-center justify-between gap-2">
'h-full rounded-full', <h3 className="font-display text-[18px] font-bold leading-tight truncate">
isDue ? 'bg-gradient-brand' : 'bg-accent' {exercise.name}
].join(' ')} </h3>
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }} <div className="relative">
transition={{ duration: 0.5, ease: 'linear' }} <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="Меню"
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden">
<button
onClick={() => {
setMenuOpen(false)
onEdit()
}}
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
>
Редактировать
</button>
<button
onClick={() => {
setMenuOpen(false)
onDelete()
}}
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
>
Удалить
</button>
</div>
</>
)}
</div>
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{exercise.reps} раз · каждые {formatInterval(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 ? 'Сейчас' : 'Через'}
</div>
<div
className={[
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
isDue ? 'text-accent' : 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label="Включить/выключить"
/>
</div>
</div> </div>
</div> </div>
<div className="relative flex items-center gap-2 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity"> {/* Done action — appears as filled pill at bottom only on due */}
<button {isDue && (
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
onClick={onMarkDone} onClick={onMarkDone}
className="flex-1 h-9 rounded-lg bg-victory/15 hover:bg-victory/25 text-victory text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-colors" 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={14} /> Сделал <Check size={15} strokeWidth={2.5} /> Готово
</button> </motion.button>
<button )}
onClick={onEdit}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text transition-colors"
aria-label="Редактировать"
>
<Pencil size={14} />
</button>
<button
onClick={onDelete}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
aria-label="Удалить"
>
<Trash2 size={14} />
</button>
</div>
</motion.div> </motion.div>
) )
} }

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Zap } from 'lucide-react'
import type { Exercise } from '@shared/types' import type { Exercise } from '@shared/types'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
@@ -56,10 +55,10 @@ export function ExerciseEditor({
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title={exercise ? 'Редактировать упражнение' : 'Новое упражнение'} title={exercise ? 'Редактировать' : 'Новое упражнение'}
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="plain" onClick={onClose}>
Отмена Отмена
</Button> </Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}> <Button disabled={!canSave} onClick={() => onSave(draft)}>
@@ -69,28 +68,17 @@ export function ExerciseEditor({
} }
> >
<div className="space-y-5"> <div className="space-y-5">
{/* Preview card */} {/* Live preview header */}
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden"> <div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" /> <div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
<div className="relative flex items-center gap-4"> <Icon name={draft.icon} size={26} strokeWidth={2.2} />
<div className="relative w-14 h-14 shrink-0"> </div>
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" /> <div className="min-w-0">
<div className="relative w-14 h-14 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow"> <div className="font-display text-[18px] font-semibold tracking-tight truncate">
<Icon name={draft.icon} size={26} /> {draft.name || 'Без названия'}
</div>
</div> </div>
<div className="min-w-0"> <div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold"> {draft.reps} раз · каждые {draft.intervalMinutes} мин
Preview
</div>
<div className="font-display font-bold text-lg uppercase tracking-wide truncate">
{draft.name || 'Без названия'}
</div>
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5 font-mono-num">
<Zap size={11} className="text-xp" />
<span className="font-bold text-text">{draft.reps}</span>
<span>повторов · каждые {draft.intervalMinutes} мин</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -99,22 +87,25 @@ export function ExerciseEditor({
<input <input
value={draft.name} value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Например, приседания" placeholder="Приседания"
className="input" className="ios-input"
autoFocus autoFocus
/> />
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<Field label="Повторений"> <Field label="Повторений">
<input <input
type="number" type="number"
min={1} min={1}
value={draft.reps} value={draft.reps}
onChange={(e) => onChange={(e) =>
setDraft({ ...draft, reps: Math.max(1, Number(e.target.value) || 1) }) setDraft({
...draft,
reps: Math.max(1, Number(e.target.value) || 1)
})
} }
className="input font-mono-num font-semibold" className="ios-input font-mono-num"
/> />
</Field> </Field>
<Field label="Интервал (мин)"> <Field label="Интервал (мин)">
@@ -128,47 +119,47 @@ export function ExerciseEditor({
intervalMinutes: Math.max(1, Number(e.target.value) || 1) intervalMinutes: Math.max(1, Number(e.target.value) || 1)
}) })
} }
className="input font-mono-num font-semibold" className="ios-input font-mono-num"
/> />
</Field> </Field>
</div> </div>
<Field label="Иконка"> <Field label="Иконка">
<div className="grid grid-cols-9 gap-2 max-h-48 overflow-y-auto pr-1"> <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) => ( {ICON_CHOICES.map((name) => (
<button <button
key={name} key={name}
type="button" type="button"
onClick={() => setDraft({ ...draft, icon: name })} onClick={() => setDraft({ ...draft, icon: name })}
className={[ className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-all', 'h-10 w-10 grid place-items-center rounded-xl transition-all active:scale-90',
draft.icon === name draft.icon === name
? 'border-accent bg-accent/15 text-accent shadow-glow scale-105' ? 'bg-accent text-white'
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40' : 'bg-surface text-text/65 hover:text-text'
].join(' ')} ].join(' ')}
> >
<Icon name={name} size={18} /> <Icon name={name} size={17} strokeWidth={2.2} />
</button> </button>
))} ))}
</div> </div>
</Field> </Field>
</div> </div>
<style>{` <style>{`
.input { .ios-input {
width: 100%; width: 100%;
height: 40px; height: 44px;
padding: 0 14px; padding: 0 14px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgb(var(--border)); border: 0;
background: rgb(var(--surface-elevated)); background: rgb(var(--surface-2));
color: rgb(var(--text)); color: rgb(var(--text));
font-size: 14px; font-size: 15px;
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s; transition: box-shadow .15s ease;
} }
.input:focus { .ios-input:focus {
border-color: rgb(var(--accent)); box-shadow: 0 0 0 2px rgb(var(--accent) / 0.45);
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
} }
`}</style> `}</style>
</Modal> </Modal>
@@ -184,7 +175,7 @@ function Field({
}): JSX.Element { }): JSX.Element {
return ( return (
<label className="block"> <label className="block">
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]"> <span className="block text-[12px] font-medium text-text/55 mb-1.5">
{label} {label}
</span> </span>
{children} {children}

View File

@@ -1,21 +1,39 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { import {
LayoutDashboard, Sun,
ListChecks,
Gamepad2,
Target,
Settings as SettingsIcon,
Dumbbell, Dumbbell,
Joystick,
Flame,
Settings2,
X X
} from 'lucide-react' } from 'lucide-react'
const links = [ type Item = {
{ to: '/', label: 'Дашборд', icon: LayoutDashboard, end: true }, to: string
{ to: '/exercises', label: 'Упражнения', icon: ListChecks }, label: string
{ to: '/games', label: 'Игры', icon: Gamepad2 }, icon: typeof Sun
{ to: '/challenges', label: 'Челленджи', icon: Target }, end?: boolean
{ to: '/settings', label: 'Настройки', icon: SettingsIcon } tint?: string
}
// Tinted icon plaques á la iOS Settings rows.
const items: Item[] = [
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
{
to: '/exercises',
label: 'Упражнения',
icon: Dumbbell,
tint: 'bg-info'
},
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
{
to: '/settings',
label: 'Настройки',
icon: Settings2,
tint: 'bg-text/70'
}
] ]
type Props = { type Props = {
@@ -23,12 +41,15 @@ type Props = {
onMobileClose?: () => void onMobileClose?: () => void
} }
export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Element { export function Sidebar({
mobileOpen = false,
onMobileClose
}: Props): JSX.Element {
return ( return (
<> <>
{/* Desktop sidebar: hidden on <md, icon-only on md, full on lg+ */} {/* Desktop sidebar — macOS vibrancy panel */}
<aside className="hidden md:flex w-16 lg:w-60 shrink-0 border-r border-border/60 bg-surface/40 backdrop-blur-sm flex-col relative"> <aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
<SidebarContent compact /> <SidebarContent />
</aside> </aside>
{/* Mobile drawer */} {/* Mobile drawer */}
@@ -39,29 +60,30 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
> >
<motion.div <motion.div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-black/30 backdrop-blur-md"
onClick={onMobileClose} onClick={onMobileClose}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
/> />
<motion.aside <motion.aside
className="relative w-64 max-w-[80vw] h-full border-r border-border/60 bg-surface flex flex-col" className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col"
initial={{ x: '-100%' }} initial={{ x: '-100%' }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: '-100%' }} exit={{ x: '-100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }} transition={{ type: 'spring', stiffness: 420, damping: 38 }}
> >
<button <button
onClick={onMobileClose} onClick={onMobileClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted transition-colors" 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="Закрыть"
> >
<X size={16} /> <X size={14} strokeWidth={2.5} />
</button> </button>
<SidebarContent compact={false} onNav={onMobileClose} /> <SidebarContent onNav={onMobileClose} />
</motion.aside> </motion.aside>
</motion.div> </motion.div>
)} )}
@@ -70,84 +92,52 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
) )
} }
function SidebarContent({ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
compact,
onNav
}: {
compact: boolean
onNav?: () => void
}): JSX.Element {
return ( return (
<> <>
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
{/* Brand */} {/* Brand */}
<div className="relative px-3 lg:px-5 py-5"> <div className="px-5 pt-7 pb-6">
<div className="flex items-center gap-3"> <div className="font-serif text-[36px] leading-none tracking-tight font-bold">
<div className="relative shrink-0"> Laude
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" /> </div>
<div className="relative w-11 h-11 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow"> <div className="text-[13px] text-text/55 mt-2 font-medium">
<Dumbbell size={20} strokeWidth={2.5} /> Двигайся осознанно
</div>
</div>
<div
className={[
'min-w-0',
compact ? 'hidden lg:block' : 'block'
].join(' ')}
>
<div className="font-display font-bold text-lg leading-none uppercase tracking-wider">
<span className="text-gradient-brand">Laude</span>
</div>
<div className="text-[10px] text-muted uppercase tracking-[0.18em] mt-1">
Move every day
</div>
</div>
</div> </div>
</div> </div>
<div className="relative px-3 lg:px-5 pb-3">
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
</div>
{/* Nav */} {/* Nav */}
<nav className="relative px-2 lg:px-3 flex flex-col gap-0.5"> <nav className="px-2.5 flex flex-col gap-1">
{links.map(({ to, label, icon: Icon, end }) => ( {items.map(({ to, label, icon: Icon, end, tint }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
end={end} end={end}
onClick={onNav} onClick={onNav}
title={compact ? label : undefined}
className={({ isActive }) => className={({ isActive }) =>
[ [
'group relative flex items-center gap-3 px-2.5 lg:px-3 py-2.5 rounded-xl text-sm font-medium transition-all', 'flex items-center gap-3 px-2.5 py-2 rounded-xl transition-colors duration-150',
compact ? 'justify-center lg:justify-start' : '',
isActive isActive
? 'text-text bg-surface-elevated/80' ? 'bg-text/[0.06] dark:bg-white/[0.08]'
: 'text-muted hover:text-text hover:bg-surface-elevated/50' : 'hover:bg-text/[0.04] dark:hover:bg-white/[0.04]'
].join(' ') ].join(' ')
} }
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<span <div
className={[ className={[
'absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all', 'w-8 h-8 rounded-[9px] grid place-items-center text-white shrink-0',
isActive tint ?? 'bg-text/70'
? 'bg-gradient-to-b from-accent to-accent-2 opacity-100 shadow-glow'
: 'opacity-0'
].join(' ')} ].join(' ')}
/> >
<Icon <Icon size={17} strokeWidth={2.2} />
size={18} </div>
className={isActive ? 'text-accent' : ''}
strokeWidth={isActive ? 2.4 : 2}
/>
<span <span
className={[ className={[
compact ? 'hidden lg:inline' : 'inline', 'text-[15px] truncate',
isActive ? 'font-semibold' : '' isActive
? 'text-text font-semibold'
: 'text-text/85 font-medium'
].join(' ')} ].join(' ')}
> >
{label} {label}
@@ -158,26 +148,14 @@ function SidebarContent({
))} ))}
</nav> </nav>
{/* Status footer — hidden on icon-only desktop */} {/* Status footer */}
<div <div className="mt-auto px-5 pb-5">
className={[ <div className="flex items-center gap-2 text-[11px] text-text/45">
'relative mt-auto p-4', <span className="relative flex h-1.5 w-1.5">
compact ? 'hidden lg:block' : 'block' <span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
].join(' ')} <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
> </span>
<div className="rounded-xl border border-border/60 bg-surface-elevated/60 p-3"> Активность отслеживается
<div className="flex items-center gap-2 mb-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-victory" />
</span>
<div className="text-[10px] uppercase tracking-[0.18em] text-muted">
Online
</div>
</div>
<div className="text-xs text-muted/80 leading-snug">
Трекинг активности включён
</div>
</div> </div>
</div> </div>
</> </>

View File

@@ -1,61 +1,78 @@
import { Minus, X, Square, Activity, Menu } from 'lucide-react' import { Minus, X, Square, Menu } from 'lucide-react'
type Props = { type Props = {
title: string title: string
onMenuClick?: () => void 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 { export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
return ( return (
<div className="titlebar-drag relative h-10 px-2 sm:px-4 flex items-center justify-between border-b border-border/60 bg-surface/50 backdrop-blur-md hud-scanlines"> <div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
<div className="flex items-center gap-2"> {/* Left: hamburger only on small */}
{/* Mobile menu — only visible on <md (where sidebar is hidden) */} <div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && ( {onMenuClick && (
<button <button
onClick={onMenuClick} onClick={onMenuClick}
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text transition-colors" 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="Открыть меню" aria-label="Меню"
> >
<Menu size={15} /> <Menu size={15} strokeWidth={2} />
</button> </button>
)} )}
<div className="flex items-center gap-2 text-xs font-medium">
<div className="relative">
<span className="absolute inset-0 rounded-full bg-accent blur-[6px] opacity-70" />
<Activity
size={12}
className="relative text-accent"
strokeWidth={2.5}
/>
</div>
<span className="hidden sm:inline uppercase tracking-[0.18em] text-muted font-display font-semibold">
{title}
</span>
</div>
</div> </div>
<div className="titlebar-nodrag flex items-center gap-1">
<button {/* Centre title */}
onClick={() => window.api.minimizeMain()} <div className="text-[12px] font-medium text-text/55 truncate px-2">
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text" {title}
aria-label="Свернуть" </div>
>
<Minus size={14} /> {/* Right window controls */}
</button> <div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<button <WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
onClick={() => window.api.hideMain()} <Minus size={13} strokeWidth={2} />
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text" </WinBtn>
aria-label="Скрыть в трей" <WinBtn onClick={() => window.api.hideMain()} label="В трей">
> <Square size={11} strokeWidth={2} />
<Square size={12} /> </WinBtn>
</button> <WinBtn
<button
onClick={() => window.api.closeMain()} onClick={() => window.api.closeMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white transition-colors text-muted" label="Закрыть"
aria-label="Закрыть" danger
> >
<X size={14} /> <X size={13} strokeWidth={2} />
</button> </WinBtn>
</div> </div>
</div> </div>
) )
} }
function WinBtn({
children,
onClick,
label,
danger = false
}: {
children: React.ReactNode
onClick: () => void
label: string
danger?: boolean
}): JSX.Element {
return (
<button
onClick={onClick}
aria-label={label}
className={[
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
danger
? 'hover:bg-destructive hover:text-white'
: 'hover:bg-text/[0.08] hover:text-text'
].join(' ')}
>
{children}
</button>
)
}

View File

@@ -9,6 +9,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Card } from './ui/Card'
import type { UpdaterStatus } from '@shared/types' import type { UpdaterStatus } from '@shared/types'
export function UpdaterCard(): JSX.Element { export function UpdaterCard(): JSX.Element {
@@ -28,7 +29,6 @@ export function UpdaterCard(): JSX.Element {
setBusy(false) setBusy(false)
} }
} }
async function download(): Promise<void> { async function download(): Promise<void> {
setBusy(true) setBusy(true)
try { try {
@@ -37,25 +37,20 @@ export function UpdaterCard(): JSX.Element {
setBusy(false) setBusy(false)
} }
} }
function install(): void { function install(): void {
void window.api.updaterInstall() void window.api.updaterInstall()
} }
return ( return (
<section className="mb-7"> <Card>
<div className="flex items-center gap-2 mb-3"> <Body
<span className="w-7 h-7 rounded-lg bg-accent/15 text-accent grid place-items-center"> status={status}
<PackageCheck size={14} /> busy={busy}
</span> onCheck={check}
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold"> onDownload={download}
Обновления onInstall={install}
</h2> />
</div> </Card>
<div className="relative rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
<Body status={status} busy={busy} onCheck={check} onDownload={download} onInstall={install} />
</div>
</section>
) )
} }
@@ -74,9 +69,9 @@ function Body({
}): JSX.Element { }): JSX.Element {
if (status.kind === 'unsupported') { if (status.kind === 'unsupported') {
return ( return (
<Row <Cell
tone="muted" tone="muted"
icon={<AlertTriangle size={18} />} icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Auto-update недоступен" title="Auto-update недоступен"
subtitle={status.reason} subtitle={status.reason}
/> />
@@ -84,23 +79,23 @@ function Body({
} }
if (status.kind === 'checking') { if (status.kind === 'checking') {
return ( return (
<Row <Cell
tone="accent" tone="info"
icon={<RefreshCw size={18} className="animate-spin" />} icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
title="Проверяем наличие обновлений…" title="Проверяем обновления…"
/> />
) )
} }
if (status.kind === 'not-available') { if (status.kind === 'not-available') {
return ( return (
<Row <Cell
tone="victory" tone="success"
icon={<CheckCircle2 size={18} />} icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title="Установлена последняя версия" title="Последняя версия"
subtitle={`Текущая: v${status.currentVersion}`} subtitle={`Текущая: v${status.currentVersion}`}
action={ action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}> <Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить <RefreshCw size={13} strokeWidth={2.5} /> Проверить
</Button> </Button>
} }
/> />
@@ -108,14 +103,18 @@ function Body({
} }
if (status.kind === 'available') { if (status.kind === 'available') {
return ( return (
<Row <Cell
tone="accent" tone="accent"
icon={<Sparkles size={18} />} icon={<Sparkles size={16} strokeWidth={2.4} />}
title={`Доступно обновление v${status.version}`} title={`Доступна v${status.version}`}
subtitle={status.releaseDate ? new Date(status.releaseDate).toLocaleString('ru-RU') : undefined} subtitle={
status.releaseDate
? new Date(status.releaseDate).toLocaleString('ru-RU')
: undefined
}
action={ action={
<Button size="sm" onClick={onDownload} disabled={busy}> <Button size="sm" onClick={onDownload} disabled={busy}>
<Download size={14} /> Скачать <Download size={13} strokeWidth={2.5} /> Скачать
</Button> </Button>
} }
/> />
@@ -125,27 +124,27 @@ function Body({
const pct = Math.max(0, Math.min(100, status.percent || 0)) const pct = Math.max(0, Math.min(100, status.percent || 0))
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1) const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
return ( return (
<div className="px-5 py-4"> <div className="px-4 py-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center"> <div className="w-10 h-10 rounded-xl bg-accent/12 text-accent grid place-items-center">
<Download size={18} /> <Download size={17} strokeWidth={2.4} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide"> <div className="text-[15px] font-semibold leading-tight">
Загружаем обновление Загружаем обновление
</div> </div>
<div className="text-xs text-muted mt-0.5 font-mono-num"> <div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '} {mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с {(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
</div> </div>
</div> </div>
<div className="font-mono-num font-bold text-lg text-accent"> <div className="font-mono-num font-bold text-[18px] text-accent">
{pct.toFixed(0)}% {pct.toFixed(0)}%
</div> </div>
</div> </div>
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden"> <div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
<motion.div <motion.div
className="h-full bg-gradient-brand" className="h-full bg-accent"
animate={{ width: `${pct}%` }} animate={{ width: `${pct}%` }}
transition={{ duration: 0.3, ease: 'linear' }} transition={{ duration: 0.3, ease: 'linear' }}
/> />
@@ -155,13 +154,13 @@ function Body({
} }
if (status.kind === 'downloaded') { if (status.kind === 'downloaded') {
return ( return (
<Row <Cell
tone="victory" tone="success"
icon={<CheckCircle2 size={18} />} icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={`Готово · v${status.version} загружена`} title={`Готово · v${status.version}`}
subtitle="Перезапустите приложение для применения" subtitle="Перезапусти для применения"
action={ action={
<Button variant="victory" size="sm" onClick={onInstall}> <Button variant="filled" size="sm" onClick={onInstall}>
Перезапустить Перезапустить
</Button> </Button>
} }
@@ -170,70 +169,70 @@ function Body({
} }
if (status.kind === 'error') { if (status.kind === 'error') {
return ( return (
<Row <Cell
tone="defeat" tone="destructive"
icon={<AlertTriangle size={18} />} icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Ошибка проверки обновлений" title="Ошибка проверки"
subtitle={status.message} subtitle={status.message}
action={ action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}> <Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Повторить <RefreshCw size={13} strokeWidth={2.5} /> Повторить
</Button> </Button>
} }
/> />
) )
} }
// idle
return ( return (
<Row <Cell
tone="muted" tone="muted"
icon={<PackageCheck size={18} />} icon={<PackageCheck size={16} strokeWidth={2.4} />}
title="Проверить наличие обновлений" title="Проверить обновления"
subtitle="Авто-проверка раз в 6 часов" subtitle="Авто-проверка раз в 6 часов"
action={ action={
<Button size="sm" onClick={onCheck} disabled={busy}> <Button size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить <RefreshCw size={13} strokeWidth={2.5} /> Проверить
</Button> </Button>
} }
/> />
) )
} }
function Row({ function Cell({
tone, tone,
icon, icon,
title, title,
subtitle, subtitle,
action action
}: { }: {
tone: 'accent' | 'victory' | 'defeat' | 'muted' tone: 'accent' | 'info' | 'success' | 'destructive' | 'muted'
icon: React.ReactNode icon: React.ReactNode
title: string title: string
subtitle?: string subtitle?: string
action?: React.ReactNode action?: React.ReactNode
}): JSX.Element { }): JSX.Element {
const toneClasses = { const cls = {
accent: 'bg-accent/15 text-accent', accent: 'bg-accent/12 text-accent',
victory: 'bg-victory/15 text-victory', info: 'bg-info/12 text-info',
defeat: 'bg-defeat/15 text-defeat', success: 'bg-success/15 text-success',
muted: 'bg-surface-elevated text-muted' destructive: 'bg-destructive/12 text-destructive',
muted: 'bg-surface-2 text-text/55'
}[tone] }[tone]
return ( return (
<div className="flex items-center gap-4 px-5 py-4"> <div className="flex items-center gap-3 px-4 py-4">
<div <div
className={[ className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0', 'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses cls
].join(' ')} ].join(' ')}
> >
{icon} {icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide"> <div className="text-[15px] font-semibold leading-tight">{title}</div>
{title}
</div>
{subtitle && ( {subtitle && (
<div className="text-xs text-muted mt-0.5 truncate">{subtitle}</div> <div className="text-[13px] text-text/65 mt-1 truncate font-medium">
{subtitle}
</div>
)} )}
</div> </div>
{action} {action}

View File

@@ -1,40 +1,60 @@
import { ButtonHTMLAttributes, forwardRef } from 'react' import { ButtonHTMLAttributes, forwardRef } from 'react'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory' /**
* iOS-style button. Three primary flavours mirror iOS's filled / tinted / plain.
* Press feedback is a subtle scale, mirroring UIKit's button highlight.
*/
type Variant = 'filled' | 'tinted' | 'plain' | 'destructive' | 'success'
type Size = 'sm' | 'md' | 'lg' type Size = 'sm' | 'md' | 'lg'
// Legacy alias — old pages used 'primary' / 'secondary' / 'ghost' / 'danger' / 'victory'.
type LegacyVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant variant?: Variant | LegacyVariant
size?: Size size?: Size
} }
const legacyMap: Record<LegacyVariant, Variant> = {
primary: 'filled',
secondary: 'tinted',
ghost: 'plain',
danger: 'destructive',
victory: 'success'
}
const variantClasses: Record<Variant, string> = { const variantClasses: Record<Variant, string> = {
primary: filled:
'bg-gradient-brand text-white shadow-glow hover:shadow-glow-lg hover:brightness-110 active:brightness-95', 'bg-accent text-white hover:brightness-105 active:brightness-95',
secondary: tinted:
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border hover:border-accent/40', 'bg-accent/12 text-accent hover:bg-accent/18 active:bg-accent/22 dark:bg-accent/20 dark:hover:bg-accent/25',
ghost: 'text-muted hover:text-text hover:bg-surface-elevated', plain: 'text-accent hover:bg-accent/10 active:bg-accent/15',
danger: 'bg-defeat text-white hover:brightness-110 shadow-soft', destructive:
victory: 'bg-destructive/12 text-destructive hover:bg-destructive/18 active:bg-destructive/22 dark:bg-destructive/20',
'bg-gradient-victory text-white shadow-glow-victory hover:brightness-110' success:
'bg-success text-white hover:brightness-105 active:brightness-95'
} }
const sizeClasses: Record<Size, string> = { const sizeClasses: Record<Size, string> = {
sm: 'h-8 px-3 text-xs', sm: 'h-8 px-3.5 text-[13px] rounded-xl',
md: 'h-10 px-4 text-sm', md: 'h-10 px-4 text-[14px] rounded-2xl',
lg: 'h-12 px-6 text-base' lg: 'h-12 px-6 text-[16px] rounded-2xl'
} }
export const Button = forwardRef<HTMLButtonElement, Props>(function Button( export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = 'primary', size = 'md', className = '', ...rest }, { variant = 'filled', size = 'md', className = '', ...rest },
ref ref
) { ) {
const v: Variant =
(legacyMap as Record<string, Variant>)[variant] ?? (variant as Variant)
return ( return (
<button <button
ref={ref} ref={ref}
className={[ className={[
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold tracking-wide transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed', 'inline-flex items-center justify-center gap-1.5 font-semibold transition-all duration-150 ease-out',
variantClasses[variant], 'disabled:opacity-40 disabled:cursor-not-allowed',
'active:scale-[0.97]',
variantClasses[v],
sizeClasses[size], sizeClasses[size],
className className
].join(' ')} ].join(' ')}

View File

@@ -0,0 +1,89 @@
import { ReactNode } from 'react'
/**
* iOS grouped-list card. Wraps rows; first/last row hairline rules handled
* by `Row` itself (only top/middle hairline shown).
*/
export function Card({
children,
className = ''
}: {
children: ReactNode
className?: string
}): JSX.Element {
return (
<div
className={[
'bg-surface rounded-2xl overflow-hidden',
'shadow-[0_0.5px_0_rgb(0_0_0_/_0.04),_0_1px_2px_rgb(0_0_0_/_0.04)]',
'dark:shadow-none dark:ring-0.5 dark:ring-hairline/30',
className
].join(' ')}
>
{children}
</div>
)
}
/**
* Section heading above a Card group — iOS Settings style: tiny uppercase
* label, generous spacing.
*/
export function SectionHeader({
title,
hint,
action
}: {
title: string
hint?: string
action?: ReactNode
}): JSX.Element {
return (
<div className="flex items-end justify-between px-4 mb-2.5">
<div>
<div className="text-[13px] font-semibold uppercase tracking-[0.06em] text-text/60">
{title}
</div>
{hint && (
<div className="text-[13px] text-text/55 mt-0.5 font-medium">
{hint}
</div>
)}
</div>
{action}
</div>
)
}
/**
* A single row inside Card — left icon (optional), title + subtitle, right
* accessory (switch / chevron / button). Adds bottom hairline unless `last`.
*/
export function Row({
children,
className = '',
onClick,
last = false
}: {
children: ReactNode
className?: string
onClick?: () => void
last?: boolean
}): JSX.Element {
const interactive = !!onClick
return (
<div
onClick={onClick}
className={[
'flex items-center gap-3 px-4 py-3 relative',
interactive
? 'cursor-pointer active:bg-surface-2 dark:active:bg-white/5 transition-colors'
: '',
!last ? 'hairline-b' : '',
className
].join(' ')}
>
{children}
</div>
)
}

View File

@@ -17,6 +17,10 @@ const sizeClass = {
lg: 'max-w-3xl' lg: 'max-w-3xl'
} }
/**
* iOS-style centred sheet. Spring-snap on enter, soft fade-out.
* Backdrop uses heavy blur for proper iOS modal feel.
*/
export function Modal({ export function Modal({
open, open,
onClose, onClose,
@@ -38,59 +42,48 @@ export function Modal({
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
className="fixed inset-0 z-50 grid place-items-center bg-black/60 backdrop-blur-md" className="fixed inset-0 z-50 grid place-items-center p-4 bg-black/40 backdrop-blur-md"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={[ className={[
'relative w-full mx-4 bg-surface rounded-2xl border border-accent/25 flex flex-col overflow-hidden', 'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
sizeClass[size] sizeClass[size]
].join(' ')} ].join(' ')}
style={{ initial={{ scale: 0.94, y: 24, opacity: 0 }}
boxShadow:
'0 0 0 1px rgb(var(--accent) / 0.1), 0 24px 80px -20px rgb(var(--accent) / 0.35), 0 28px 60px -20px rgb(0 0 0 / 0.6)'
}}
initial={{ scale: 0.95, y: 16, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }} animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.96, y: 8, opacity: 0 }} exit={{ scale: 0.96, y: 12, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 26 }} transition={{ type: 'spring', stiffness: 400, damping: 32 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Glow accent corner */} {/* Header — iOS large modal title */}
<div className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-accent/20 blur-3xl pointer-events-none" /> <div className="flex items-center justify-between px-5 pt-5 pb-3">
<div className="absolute -bottom-24 -left-24 w-56 h-56 rounded-full bg-accent-2/15 blur-3xl pointer-events-none" /> <h2 className="font-display text-[20px] font-semibold tracking-tight">
{title}
<div className="relative flex items-center justify-between px-5 py-4"> </h2>
<div className="flex items-center gap-2.5">
<span className="w-1 h-5 rounded-full bg-gradient-brand" />
<h3 className="font-display font-bold text-base uppercase tracking-wider">
{title}
</h3>
</div>
<button <button
onClick={onClose} onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted transition-colors" className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
aria-label="Закрыть" aria-label="Закрыть"
> >
<X size={16} /> <X size={14} strokeWidth={2.5} />
</button> </button>
</div> </div>
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
<div className="relative px-5 py-4 overflow-y-auto max-h-[70vh]"> <div className="px-5 pb-5 overflow-y-auto max-h-[70vh]">
{children} {children}
</div> </div>
{footer && ( {footer && (
<> <div className="hairline-t px-5 py-3 flex justify-end gap-2 bg-surface">
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" /> {footer}
<div className="relative px-5 py-4 flex justify-end gap-2">{footer}</div> </div>
</>
)} )}
</motion.div> </motion.div>
</motion.div> </motion.div>

View File

@@ -1,3 +1,5 @@
import { motion } from 'framer-motion'
type Props = { type Props = {
checked: boolean checked: boolean
onChange: (next: boolean) => void onChange: (next: boolean) => void
@@ -5,7 +7,15 @@ type Props = {
'aria-label'?: string 'aria-label'?: string
} }
export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Element { /**
* iOS UISwitch — 51×31 spec, green when on, smooth spring knob.
*/
export function Switch({
checked,
onChange,
disabled,
...rest
}: Props): JSX.Element {
return ( return (
<button <button
type="button" type="button"
@@ -15,19 +25,20 @@ export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Ele
onClick={() => !disabled && onChange(!checked)} onClick={() => !disabled && onChange(!checked)}
disabled={disabled} disabled={disabled}
className={[ className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/50', 'relative inline-flex h-[31px] w-[51px] shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-out',
checked checked ? 'bg-success' : 'bg-hairline/25 dark:bg-hairline/50',
? 'bg-gradient-brand shadow-glow' disabled ? 'opacity-40 cursor-not-allowed' : ''
: 'bg-surface-elevated border border-border',
disabled ? 'opacity-50 cursor-not-allowed' : ''
].join(' ')} ].join(' ')}
style={{ padding: 2 }}
> >
<span <motion.span
className={[ className="block h-[27px] w-[27px] rounded-full bg-white"
'inline-block h-5 w-5 transform rounded-full bg-white shadow-soft transition-transform duration-200', style={{
checked ? 'translate-x-[22px]' : 'translate-x-0.5', boxShadow:
'mt-0.5' '0 3px 8px rgba(0,0,0,0.15), 0 3px 1px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(0,0,0,0.04)'
].join(' ')} }}
animate={{ x: checked ? 20 : 0 }}
transition={{ type: 'spring', stiffness: 700, damping: 35 }}
/> />
</button> </button>
) )

View File

@@ -1,20 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import { Plus, ChevronRight, AlertTriangle, Gamepad2 } from 'lucide-react'
Plus,
Pencil,
Trash2,
Gamepad2,
Target,
AlertTriangle
} from 'lucide-react'
import { motion } from 'framer-motion'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Modal } from '../components/ui/Modal' import { Modal } from '../components/ui/Modal'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { ICON_CHOICES, Icon } from '../lib/icon' import { ICON_CHOICES, Icon } from '../lib/icon'
import { GAME_STATS, STAT_LABELS } from '@shared/types' import { GAME_STATS, STAT_LABELS } from '@shared/types'
import type { Challenge, GameId, GameStat, GameStatus } from '@shared/types' import type {
Challenge,
GameId,
GameStat,
GameStatus
} from '@shared/types'
const GAME_NAMES: Record<GameId, string> = { const GAME_NAMES: Record<GameId, string> = {
dota2: 'Dota 2' dota2: 'Dota 2'
@@ -43,147 +41,111 @@ export default function ChallengesPage(): JSX.Element {
return window.api.onGamesChanged(setGames) return window.api.onGamesChanged(setGames)
}, []) }, [])
const activeCount = challenges.filter((c) => c.enabled).length const noGamesActive = games.length > 0 && !games.some((g) => g.enabled)
return ( return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-3xl"> <div className="h-full overflow-y-auto">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="min-w-0"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2"> <div>
Правила за матч <div className="text-[14px] text-text/65 font-semibold">
</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">
После матча · повторов = <span className="text-text font-mono-num">статистика × коэффициент</span>
{activeCount > 0 && (
<>
{' · '}
<span className="text-accent font-mono-num font-bold">
{activeCount} активных
</span>
</>
)}
</p>
</div>
<Button
className="self-start sm:self-auto flex-shrink-0"
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={16} /> Новый
</Button>
</div>
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
{challenges.map((c, i) => (
<motion.div
key={c.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }}
className={[
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
c.enabled
? 'hover:bg-accent/[0.04]'
: 'opacity-70 hover:opacity-100',
i < challenges.length - 1 ? 'border-b border-border/40' : ''
].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>
<div className="flex-1 min-w-0"> <h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
<div className="font-display font-semibold tracking-wide truncate text-base"> Челленджи
{c.name} </h1>
</div> <p className="text-[15px] text-text/65 mt-2 font-medium">
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5 flex-wrap"> Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
<Gamepad2 size={11} className="text-muted" />
<span>{GAME_NAMES[c.gameId]}</span>
<span className="text-border">·</span>
<span className="font-mono-num">
<span className="text-text font-semibold">
{STAT_LABELS[c.stat]}
</span>{' '}
×{' '}
<span className="text-accent font-bold">{c.multiplier}</span>
</span>
<span className="text-border"></span>
<span className="text-text font-semibold">
{c.exerciseName}
</span>
</div>
</div>
<Switch
checked={c.enabled}
onChange={(v) => window.api.toggleChallenge(c.id, v)}
/>
<div className="flex items-center gap-1 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<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 transition-colors"
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-defeat/15 hover:text-defeat text-muted transition-colors"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
</motion.div>
))}
{challenges.length === 0 && (
<div className="px-5 py-16 text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
<Target size={26} />
</div>
<div className="font-display text-lg font-semibold uppercase tracking-wider">
Челленджей нет
</div>
<p className="text-sm text-muted mt-1">
Привяжи первое упражнение к статистике матча
</p> </p>
</div> </div>
)} <Button
</div> onClick={() => {
setEditing(null)
{games.length > 0 && !games.some((g) => g.enabled) && ( setEditorOpen(true)
<div className="mt-6 rounded-xl bg-xp/10 border border-xp/30 p-4 text-sm flex items-start gap-2.5"> }}
<AlertTriangle size={16} className="text-xp shrink-0 mt-0.5" /> >
<div> <Plus size={15} strokeWidth={2.5} /> Новый
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '} </Button>
<strong className="text-text">Игры</strong>.
</div>
</div> </div>
)}
<ChallengeEditor {noGamesActive && (
open={editorOpen} <div className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-start gap-3">
challenge={editing} <div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
onClose={() => setEditorOpen(false)} <AlertTriangle size={18} strokeWidth={2.5} />
onSave={async (draft) => { </div>
if (editing) await window.api.updateChallenge(editing.id, draft) <div className="text-[14px] text-text/85 leading-relaxed font-medium">
else await window.api.addChallenge(draft) Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
setEditorOpen(false) <span className="font-semibold text-text">«Игры»</span>.
}} </div>
/> </div>
)}
{challenges.length > 0 ? (
<>
<SectionHeader title={`Все · ${challenges.length}`} />
<Card>
{challenges.map((c, i) => (
<Row
key={c.id}
last={i === challenges.length - 1}
onClick={() => {
setEditing(c)
setEditorOpen(true)
}}
>
<div
className={[
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
c.enabled
? 'bg-warning text-white'
: 'bg-text/15 text-text/45'
].join(' ')}
>
<Icon name={c.icon} size={18} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold truncate leading-tight">
{c.name}
</div>
<div className="text-[14px] text-text/65 mt-1 inline-flex items-center gap-1.5 font-medium">
<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}
</span>{' '}
{c.exerciseName}
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={c.enabled}
onChange={(v) => window.api.toggleChallenge(c.id, v)}
/>
</div>
<ChevronRight size={16} className="text-text/30" />
</Row>
))}
</Card>
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Челленджей пока нет. Привяжи упражнение к статистике матча.
</div>
</Card>
)}
<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>
</div> </div>
) )
} }
@@ -228,10 +190,10 @@ function ChallengeEditor({
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title={challenge ? 'Редактировать челлендж' : 'Новый челлендж'} title={challenge ? 'Редактировать' : 'Новый челлендж'}
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="plain" onClick={onClose}>
Отмена Отмена
</Button> </Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}> <Button disabled={!canSave} onClick={() => onSave(draft)}>
@@ -245,8 +207,8 @@ function ChallengeEditor({
<input <input
value={draft.name} value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Например, за смерти — приседания" placeholder="За смерти — приседания"
className="input" className="ios-input"
autoFocus autoFocus
/> />
</Field> </Field>
@@ -257,7 +219,7 @@ function ChallengeEditor({
onChange={(e) => onChange={(e) =>
setDraft({ ...draft, gameId: e.target.value as GameId }) setDraft({ ...draft, gameId: e.target.value as GameId })
} }
className="input" className="ios-input"
> >
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => ( {(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
<option key={id} value={id}> <option key={id} value={id}>
@@ -267,14 +229,14 @@ function ChallengeEditor({
</select> </select>
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<Field label="Стат"> <Field label="Статистика">
<select <select
value={draft.stat} value={draft.stat}
onChange={(e) => onChange={(e) =>
setDraft({ ...draft, stat: e.target.value as GameStat }) setDraft({ ...draft, stat: e.target.value as GameStat })
} }
className="input" className="ios-input"
> >
{GAME_STATS[draft.gameId].map((s) => ( {GAME_STATS[draft.gameId].map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
@@ -295,7 +257,7 @@ function ChallengeEditor({
multiplier: Math.max(0.5, Number(e.target.value) || 1) multiplier: Math.max(0.5, Number(e.target.value) || 1)
}) })
} }
className="input" className="ios-input font-mono-num"
/> />
</Field> </Field>
</div> </div>
@@ -306,74 +268,68 @@ function ChallengeEditor({
onChange={(e) => onChange={(e) =>
setDraft({ ...draft, exerciseName: e.target.value }) setDraft({ ...draft, exerciseName: e.target.value })
} }
placeholder="Например, приседания" placeholder="Приседания"
className="input" className="ios-input"
/> />
</Field> </Field>
<Field label="Иконка"> <Field label="Иконка">
<div className="grid grid-cols-9 gap-2 max-h-40 overflow-y-auto pr-1"> <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) => ( {ICON_CHOICES.map((name) => (
<button <button
key={name} key={name}
type="button" type="button"
onClick={() => setDraft({ ...draft, icon: name })} onClick={() => setDraft({ ...draft, icon: name })}
className={[ className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-colors', 'h-10 w-10 grid place-items-center rounded-xl transition-all active:scale-90',
draft.icon === name draft.icon === name
? 'border-accent bg-accent/15 text-accent shadow-glow' ? 'bg-accent text-white'
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40' : 'bg-surface text-text/65 hover:text-text'
].join(' ')} ].join(' ')}
> >
<Icon name={name} size={18} /> <Icon name={name} size={17} strokeWidth={2.2} />
</button> </button>
))} ))}
</div> </div>
</Field> </Field>
{/* Formula preview */} {/* Live preview */}
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden"> <div className="rounded-2xl bg-accent/8 p-4">
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" /> <div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
<div className="relative"> Превью · 5 событий
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold mb-2"> </div>
Preview · если 5 событий <div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
</div> <span>5 {STAT_LABELS[draft.stat]}</span>
<div className="flex items-center gap-2 text-sm font-mono-num"> <span className="text-text/40">×</span>
<span className="font-bold text-text">5</span> <span>{draft.multiplier}</span>
<span className="text-muted">{STAT_LABELS[draft.stat]}</span> <span className="text-text/40">=</span>
<span className="text-muted">×</span> <span className="text-[32px] font-display font-semibold text-accent leading-none ml-1 tracking-tight">
<span className="font-bold text-accent">{draft.multiplier}</span> {previewReps}
<span className="text-muted">=</span> </span>
<span className="text-3xl font-bold text-gradient-brand leading-none ml-1"> <span className="text-text/55">
{previewReps} {draft.exerciseName.toLowerCase() || 'повторов'}
</span> </span>
<span className="text-muted ml-1">
{draft.exerciseName.toLowerCase() || 'упражнений'}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<style>{` <style>{`
.input { .ios-input {
width: 100%; width: 100%;
height: 40px; height: 44px;
padding: 0 14px; padding: 0 14px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgb(var(--border)); border: 0;
background: rgb(var(--surface-elevated)); background: rgb(var(--surface-2));
color: rgb(var(--text)); color: rgb(var(--text));
font-size: 14px; font-size: 15px;
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s; transition: box-shadow .15s ease;
} }
.input:focus { .ios-input:focus {
border-color: rgb(var(--accent)); box-shadow: 0 0 0 2px rgb(var(--accent) / 0.45);
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
}
select.input {
padding-right: 32px;
} }
select.ios-input { padding-right: 32px; }
`}</style> `}</style>
</Modal> </Modal>
) )
@@ -388,7 +344,7 @@ function Field({
}): JSX.Element { }): JSX.Element {
return ( return (
<label className="block"> <label className="block">
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]"> <span className="block text-[12px] font-medium text-text/55 mb-1.5">
{label} {label}
</span> </span>
{children} {children}

View File

@@ -1,21 +1,12 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
Plus,
Pause,
Play,
Timer,
Flame,
Activity,
Gamepad2,
Trophy
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types' import type { Exercise } from '@shared/types'
import { formatCountdown, formatInterval } from '../lib/format' import { formatCountdown } from '../lib/format'
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
@@ -25,7 +16,6 @@ export default function Dashboard(): JSX.Element {
const exercises = state?.exercises ?? [] const exercises = state?.exercises ?? []
const settings = state?.settings const settings = state?.settings
const challenges = state?.challenges ?? []
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
const stats = useMemo(() => { const stats = useMemo(() => {
@@ -33,32 +23,24 @@ export default function Dashboard(): JSX.Element {
const next = enabled const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() })) .map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
.sort((a, b) => a.ms - b.ms)[0] .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 { return {
total: exercises.length, total: exercises.length,
active: enabled.length, active: enabled.length,
nextMs: next?.ms ?? Infinity, nextMs: next?.ms ?? Infinity,
totalReps, totalReps: enabled.reduce((s, e) => s + e.reps, 0)
avgInterval
} }
}, [exercises, ticks]) }, [exercises, ticks])
const paused = !settings?.globalEnabled
function openCreate(): void { function openCreate(): void {
setEditing(null) setEditing(null)
setEditorOpen(true) setEditorOpen(true)
} }
function openEdit(ex: Exercise): void { function openEdit(ex: Exercise): void {
setEditing(ex) setEditing(ex)
setEditorOpen(true) setEditorOpen(true)
} }
async function handleSave(draft: { async function handleSave(draft: {
name: string name: string
reps: number reps: number
@@ -66,221 +48,199 @@ export default function Dashboard(): JSX.Element {
intervalMinutes: number intervalMinutes: number
enabled: boolean enabled: boolean
}): Promise<void> { }): Promise<void> {
if (editing) { if (editing) await window.api.updateExercise(editing.id, draft)
await window.api.updateExercise(editing.id, draft) else await window.api.addExercise(draft)
} else {
await window.api.addExercise(draft)
}
setEditorOpen(false) setEditorOpen(false)
} }
async function handleDelete(id: string): Promise<void> {
await window.api.deleteExercise(id)
}
async function togglePause(): Promise<void> { async function togglePause(): Promise<void> {
if (!settings) return if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled }) 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 ( return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full"> <div className="h-full overflow-y-auto">
{/* Hero header */} <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6"> {/* Hero — iOS Large Title */}
<div className="min-w-0"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2"> <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">
Сегодня
</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> </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>
<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 */} {/* Hero stat panel — Apple Fitness style */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
<HudStat <HeroStat
icon={<Timer size={18} />} tone="accent"
label="До следующего" label="Активных"
value={ value={`${stats.active}`}
stats.nextMs === Infinity subvalue={`из ${stats.total}`}
? '—' icon={<Activity size={14} strokeWidth={2.6} />}
: stats.nextMs <= 0 />
? 'СЕЙЧАС' <HeroStat
: formatCountdown(stats.nextMs) tone="info"
} label="До следующего"
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity} value={
/> stats.nextMs === Infinity
<HudStat ? '—'
icon={<Activity size={18} />} : stats.nextMs <= 0
label="Активных" ? 'Сейчас'
value={`${stats.active}/${stats.total}`} : formatCountdown(stats.nextMs)
/> }
<HudStat subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
icon={<Flame size={18} />} icon={<Flame size={14} strokeWidth={2.6} />}
label="Avg интервал" />
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'} <HeroStat
/> tone={gamesEnabled ? 'success' : 'muted'}
<HudStat label="Трекинг матчей"
icon={<Gamepad2 size={18} />} value={gamesEnabled ? 'On' : 'Off'}
label="Трекинг матчей" subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
value={gamesEnabled ? 'LIVE' : 'OFF'} icon={
accent={gamesEnabled} <span
tone={gamesEnabled ? 'victory' : 'muted'} 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-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
<Pause size={18} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
Напоминания на паузе
</div>
<div className="text-[14px] text-text/70 mt-1">
Возобнови, чтобы продолжить отсчёт
</div>
</div>
<Button variant="filled" size="sm" onClick={togglePause}>
<Play size={14} 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> </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> </div>
) )
} }
function HudStat({ function HeroStat({
icon, tone,
label, label,
value, value,
accent, subvalue,
tone = 'accent' icon
}: { }: {
icon: React.ReactNode tone: 'accent' | 'info' | 'success' | 'muted'
label: string label: string
value: string value: string
accent?: boolean subvalue?: string
tone?: 'accent' | 'victory' | 'muted' icon?: React.ReactNode
}): JSX.Element { }): JSX.Element {
const toneClasses = const toneBg =
tone === 'victory' tone === 'accent'
? 'text-victory bg-victory/15' ? 'bg-accent'
: tone === 'muted' : tone === 'info'
? 'text-muted bg-surface-elevated' ? 'bg-info'
: 'text-accent bg-accent/15' : tone === 'success'
? 'bg-success'
: 'bg-text/40'
return ( return (
<div <div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
className={[ <div className="flex items-center gap-2 mb-3">
'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 <div
className={[ className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0', 'w-7 h-7 rounded-lg grid place-items-center text-white',
toneClasses toneBg
].join(' ')} ].join(' ')}
> >
{icon} {icon}
</div> </div>
<div className="min-w-0"> <div className="text-[14px] text-text/75 font-semibold">{label}</div>
<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> </div>
<div className="font-display text-[28px] font-bold tracking-tight leading-none">
{value}
</div>
{subvalue && (
<div className="text-[13px] text-text/60 mt-2 font-medium">
{subvalue}
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,10 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Pencil, Trash2, ListChecks } from 'lucide-react' import { Plus, ChevronRight } from 'lucide-react'
import { motion } from 'framer-motion'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format' import { formatInterval } from '../lib/format'
import type { Exercise } from '@shared/types' import type { Exercise } from '@shared/types'
@@ -14,138 +14,137 @@ export default function Exercises(): JSX.Element {
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null) const [editing, setEditing] = useState<Exercise | null>(null)
const enabledCount = exercises.filter((e) => e.enabled).length const enabled = exercises.filter((e) => e.enabled)
const totalReps = exercises const disabled = exercises.filter((e) => !e.enabled)
.filter((e) => e.enabled)
.reduce((s, e) => s + e.reps, 0)
return ( return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full"> <div className="h-full overflow-y-auto">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="min-w-0"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2"> <div>
Программа <div className="text-[14px] text-text/65 font-semibold">
Программа
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Упражнения
</h1>
</div> </div>
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide"> <Button
<span className="text-gradient-brand">Упражнения</span> onClick={() => {
</h1> setEditing(null)
<p className="text-sm text-muted mt-2"> setEditorOpen(true)
<span className="text-text font-mono-num font-semibold"> }}
{enabledCount}
</span>{' '}
активных ·{' '}
<span className="text-text font-mono-num font-semibold">
{totalReps}
</span>{' '}
повторов за цикл
</p>
</div>
<Button
className="self-start sm:self-auto flex-shrink-0"
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={16} /> Добавить
</Button>
</div>
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
{exercises.map((ex, i) => (
<motion.div
key={ex.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.02 }}
className={[
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
ex.enabled
? 'hover:bg-accent/[0.04]'
: 'opacity-70 hover:opacity-100',
i < exercises.length - 1 ? 'border-b border-border/40' : ''
].join(' ')}
> >
<div <Plus size={15} strokeWidth={2.5} /> Добавить
className={[ </Button>
'w-11 h-11 rounded-xl grid place-items-center shrink-0 transition-colors', </div>
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-display font-semibold tracking-wide truncate text-base">
{ex.name}
</div>
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-3">
<span>
<span className="font-mono-num font-bold text-text">
{ex.reps}
</span>{' '}
повторов
</span>
<span className="text-border">·</span>
<span>
каждые{' '}
<span className="font-mono-num text-text font-semibold">
{formatInterval(ex.intervalMinutes)}
</span>
</span>
</div>
</div>
<Switch
checked={ex.enabled}
onChange={(v) => window.api.toggleExercise(ex.id, v)}
/>
<div className="flex items-center gap-1 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<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 transition-colors"
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-defeat/15 hover:text-defeat text-muted transition-colors"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
</motion.div>
))}
{exercises.length === 0 && (
<div className="px-5 py-16 text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
<ListChecks size={26} />
</div>
<div className="font-display text-lg font-semibold uppercase tracking-wider">
Список пуст
</div>
<p className="text-sm text-muted mt-1">
Добавь первое упражнение через кнопку выше
</p>
</div>
)}
</div>
<ExerciseEditor {enabled.length > 0 && (
open={editorOpen} <>
exercise={editing} <SectionHeader title={`Активные · ${enabled.length}`} />
onClose={() => setEditorOpen(false)} <Card className="mb-6">
onSave={async (draft) => { {enabled.map((ex, i) => (
if (editing) await window.api.updateExercise(editing.id, draft) <ExerciseRow
else await window.api.addExercise(draft) key={ex.id}
setEditorOpen(false) exercise={ex}
}} last={i === enabled.length - 1}
/> onEdit={() => {
setEditing(ex)
setEditorOpen(true)
}}
/>
))}
</Card>
</>
)}
{disabled.length > 0 && (
<>
<SectionHeader title={`Выключенные · ${disabled.length}`} />
<Card>
{disabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
exercise={ex}
last={i === disabled.length - 1}
onEdit={() => {
setEditing(ex)
setEditorOpen(true)
}}
/>
))}
</Card>
</>
)}
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
Программа пуста добавь первое упражнение
</div>
</Card>
)}
<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>
</div> </div>
) )
} }
function ExerciseRow({
exercise,
last,
onEdit
}: {
exercise: Exercise
last: boolean
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',
exercise.enabled
? 'bg-accent text-white'
: 'bg-text/15 text-text/45'
].join(' ')}
>
<Icon name={exercise.icon} size={18} strokeWidth={2.2} />
</div>
<button
onClick={onEdit}
className="flex-1 min-w-0 text-left active:opacity-70 transition-opacity"
>
<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>
</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>
</Row>
)
}

View File

@@ -6,12 +6,12 @@ import {
CheckCircle2, CheckCircle2,
Hourglass, Hourglass,
Gamepad2, Gamepad2,
Radio,
AlertTriangle AlertTriangle
} from 'lucide-react' } from 'lucide-react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types' import type { GameId, GameStatus } from '@shared/types'
export default function GamesPage(): JSX.Element { export default function GamesPage(): JSX.Element {
@@ -27,7 +27,6 @@ export default function GamesPage(): JSX.Element {
async function refresh(): Promise<void> { async function refresh(): Promise<void> {
setGames(await window.api.listGames()) setGames(await window.api.listGames())
} }
async function install(id: GameId): Promise<void> { async function install(id: GameId): Promise<void> {
setBusy(id) setBusy(id)
try { try {
@@ -36,7 +35,6 @@ export default function GamesPage(): JSX.Element {
setBusy(null) setBusy(null)
} }
} }
async function uninstall(id: GameId): Promise<void> { async function uninstall(id: GameId): Promise<void> {
setBusy(id) setBusy(id)
try { try {
@@ -45,7 +43,6 @@ export default function GamesPage(): JSX.Element {
setBusy(null) setBusy(null)
} }
} }
async function toggle(id: GameId, enabled: boolean): Promise<void> { async function toggle(id: GameId, enabled: boolean): Promise<void> {
setBusy(id) setBusy(id)
try { try {
@@ -55,71 +52,72 @@ export default function GamesPage(): JSX.Element {
} }
} }
const liveCount = games.filter((g) => g.enabled && g.integrationActive).length const liveCount = games.filter(
(g) => g.enabled && g.integrationActive
).length
return ( return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-3xl"> <div className="h-full overflow-y-auto">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="min-w-0"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2"> <div>
Трекинг матчей <div className="text-[14px] text-text/65 font-semibold">
</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">
Подключи игру челленджи сработают сразу после матча
{liveCount > 0 && (
<>
{' · '}
<span className="text-victory font-mono-num font-bold">
{liveCount} LIVE
</span>
</>
)}
</p>
</div>
<Button
variant="secondary"
className="self-start sm:self-auto flex-shrink-0"
onClick={refresh}
>
<RefreshCw size={16} /> Обновить
</Button>
</div>
<div className="space-y-4">
{games.map((g, i) => (
<motion.div
key={g.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<GameRow
game={g}
busy={busy === g.id}
onInstall={() => install(g.id)}
onUninstall={() => uninstall(g.id)}
onToggle={(v) => toggle(g.id, v)}
/>
</motion.div>
))}
{games.length === 0 && (
<div className="px-5 py-12 text-center rounded-2xl border border-border/60 bg-surface/40">
<div className="font-display text-base text-muted uppercase tracking-wider">
Сканируем
</div> </div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Игры
</h1>
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
Подключи игру челленджи сработают сразу после матча
{liveCount > 0 && (
<>
{' · '}
<span className="text-success font-mono-num font-bold">
{liveCount} live
</span>
</>
)}
</p>
</div> </div>
)} <Button variant="tinted" onClick={refresh}>
</div> <RefreshCw size={14} strokeWidth={2.5} /> Обновить
</Button>
</div>
<DevPanel games={games} /> <SectionHeader title="Поддерживаемые" />
<div className="space-y-4">
{games.map((g, i) => (
<motion.div
key={g.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
>
<GameCard
game={g}
busy={busy === g.id}
onInstall={() => install(g.id)}
onUninstall={() => uninstall(g.id)}
onToggle={(v) => toggle(g.id, v)}
/>
</motion.div>
))}
{games.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Сканируем установленные игры
</div>
</Card>
)}
</div>
<DevPanel games={games} />
</div>
</div> </div>
) )
} }
function GameRow({ function GameCard({
game, game,
busy, busy,
onInstall, onInstall,
@@ -139,77 +137,54 @@ function GameRow({
game.enabled game.enabled
return ( return (
<div <div className="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
className={[ <div className="flex items-start justify-between gap-4">
'relative rounded-2xl border bg-surface/70 backdrop-blur-sm p-5 overflow-hidden transition-colors',
isLive
? 'border-victory/40 shadow-glow-victory'
: game.integrationActive
? 'border-accent/30'
: 'border-border/70 hover:border-accent/30'
].join(' ')}
>
{/* Glow corner */}
<div
className={[
'absolute -top-10 -right-10 w-40 h-40 rounded-full blur-3xl pointer-events-none',
isLive ? 'bg-victory/20' : game.integrationActive ? 'bg-accent/15' : ''
].join(' ')}
/>
<div className="relative flex items-start justify-between gap-4">
<div className="flex items-start gap-4 min-w-0 flex-1"> <div className="flex items-start gap-4 min-w-0 flex-1">
{/* Game icon plaque */} <div
<div className="relative shrink-0"> className={[
<div 'w-12 h-12 rounded-2xl grid place-items-center shrink-0 text-white',
className={[ isLive
'absolute inset-0 rounded-2xl blur-md opacity-60', ? 'bg-success'
isLive : game.integrationActive
? 'bg-gradient-victory' ? 'bg-accent'
: game.integrationActive : 'bg-text/30'
? 'bg-gradient-brand' ].join(' ')}
: '' >
].join(' ')} <Gamepad2 size={22} strokeWidth={2.3} />
/>
<div
className={[
'relative w-14 h-14 rounded-2xl grid place-items-center text-white',
isLive
? 'bg-gradient-victory shadow-glow-victory'
: game.integrationActive
? 'bg-gradient-brand shadow-glow'
: 'bg-surface-elevated text-muted border border-border'
].join(' ')}
>
<Gamepad2 size={26} />
</div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-display font-bold text-lg uppercase tracking-wide"> <h3 className="font-display text-[18px] font-bold tracking-tight">
{game.name} {game.name}
</h3> </h3>
<StatusBadge game={game} isLive={isLive} /> <StatusBadge game={game} isLive={isLive} />
</div> </div>
{game.installPath && ( {game.installPath && (
<div className="text-[11px] text-muted mt-1.5 truncate font-mono opacity-70"> <div className="text-[13px] text-text/55 mt-1.5 truncate font-mono-num font-medium">
{game.installPath} {game.installPath}
</div> </div>
)} )}
</div> </div>
</div> </div>
{game.installed && game.integrationActive && ( {game.installed && game.integrationActive && (
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} /> <Switch
checked={game.enabled}
onChange={onToggle}
disabled={busy}
/>
)} )}
</div> </div>
{game.integrationActive && game.launchOptionStatus === 'queued' && ( {game.integrationActive && game.launchOptionStatus === 'queued' && (
<div className="relative mt-4 rounded-xl bg-xp/10 border border-xp/30 p-3 text-sm flex items-start gap-2.5"> <div className="mt-4 rounded-2xl bg-warning/12 p-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
<Hourglass size={16} className="text-xp shrink-0 mt-0.5" /> <Hourglass
<div> size={17}
className="text-warning shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/85">
Steam запущен. Параметр{' '} Steam запущен. Параметр{' '}
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs"> <code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
{game.launchOption} {game.launchOption}
</code>{' '} </code>{' '}
пропишется автоматически при следующем закрытии Steam. пропишется автоматически при следующем закрытии Steam.
@@ -218,29 +193,38 @@ function GameRow({
)} )}
{game.integrationActive && game.launchOptionStatus === 'no_user' && ( {game.integrationActive && game.launchOptionStatus === 'no_user' && (
<div className="relative mt-4 rounded-xl bg-defeat/10 border border-defeat/30 p-3 text-sm flex items-start gap-2.5"> <div className="mt-4 rounded-2xl bg-destructive/10 p-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
<AlertTriangle size={16} className="text-defeat shrink-0 mt-0.5" /> <AlertTriangle
<div> size={17}
className="text-destructive shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/85">
В Steam нет залогиненного аккаунта (нет папки{' '} В Steam нет залогиненного аккаунта (нет папки{' '}
<code className="font-mono text-xs">userdata</code>). Запусти Steam <code className="font-mono-num text-[13px] font-semibold">userdata</code>).
один раз потом снова нажми «Установить интеграцию». Запусти Steam один раз и нажми «Установить интеграцию».
</div> </div>
</div> </div>
)} )}
<div className="relative flex items-center flex-wrap gap-2 mt-4"> <div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && ( {game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy}> <Button onClick={onInstall} disabled={busy} size="sm">
<Download size={16} /> Установить интеграцию <Download size={14} strokeWidth={2.5} /> Подключить
</Button> </Button>
)} )}
{game.integrationActive && ( {game.integrationActive && (
<Button variant="secondary" onClick={onUninstall} disabled={busy}> <Button
<Trash2 size={16} /> Удалить интеграцию variant="tinted"
onClick={onUninstall}
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> Отключить
</Button> </Button>
)} )}
{!game.installed && ( {!game.installed && (
<div className="text-xs text-muted"> <div className="text-[14px] text-text/65 font-medium">
Установи игру в Steam и нажми «Обновить» Установи игру в Steam и нажми «Обновить»
</div> </div>
)} )}
@@ -258,10 +242,10 @@ function StatusBadge({
}): JSX.Element { }): JSX.Element {
if (isLive) { if (isLive) {
return ( return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-victory/15 text-victory font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5"> <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">
<span className="relative flex h-1.5 w-1.5"> <span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-70 animate-ping" /> <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-victory" /> <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span> </span>
Live Live
</span> </span>
@@ -269,28 +253,28 @@ function StatusBadge({
} }
if (game.integrationActive && game.launchOptionStatus === 'applied') { if (game.integrationActive && game.launchOptionStatus === 'applied') {
return ( return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5"> <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} /> Ready <CheckCircle2 size={11} strokeWidth={2.5} /> Готово
</span> </span>
) )
} }
if (game.integrationActive && game.launchOptionStatus === 'queued') { if (game.integrationActive && game.launchOptionStatus === 'queued') {
return ( return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-xp/15 text-xp font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5"> <span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
<Radio size={11} /> Queued В очереди
</span> </span>
) )
} }
if (game.installed) { if (game.installed) {
return ( return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-text font-display font-bold uppercase tracking-widest"> <span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
Installed Установлена
</span> </span>
) )
} }
return ( return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-display font-bold uppercase tracking-widest"> <span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
Not found Не найдена
</span> </span>
) )
} }
@@ -300,10 +284,10 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const dota = games.find((g) => g.id === 'dota2') const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null if (!dota?.enabled) return null
return ( return (
<div className="mt-8 pt-6 border-t border-border/40"> <div className="mt-10">
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="text-[10px] uppercase tracking-[0.18em] text-muted hover:text-accent font-mono font-semibold transition-colors" className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
> >
{open ? '▾' : '▸'} dev · симулировать конец матча {open ? '▾' : '▸'} dev · симулировать конец матча
</button> </button>
@@ -323,7 +307,7 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
<button <button
key={p.label} key={p.label}
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)} onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-accent/15 hover:text-accent text-muted font-mono transition-colors border border-border/60" className="text-[12px] px-3 py-1.5 rounded-full bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/70 font-medium transition-colors active:scale-95"
> >
{p.label} {p.label}
</button> </button>

View File

@@ -1,164 +1,146 @@
import { Bell, Monitor, Palette } from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard' import { UpdaterCard } from '../components/UpdaterCard'
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types' import type {
NotificationMode,
Settings as SettingsType,
Theme
} from '@shared/types'
export default function SettingsPage(): JSX.Element { export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
if (!settings) if (!settings)
return ( return <div className="p-8 text-text/45">Загрузка</div>
<div className="p-8 text-muted font-display uppercase tracking-wider">
Загрузка
</div>
)
const patch = (p: Partial<SettingsType>): void => { const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p) window.api.updateSettings(p)
} }
return ( return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-2xl"> <div className="h-full overflow-y-auto">
<div className="mb-8"> <div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2"> <div className="mb-8">
Конфигурация <div className="text-[14px] text-text/65 font-semibold">
Конфигурация
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Настройки
</h1>
</div> </div>
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
<span className="text-gradient-brand">Настройки</span> {/* Reminders */}
</h1> <SectionHeader title="Напоминания" />
<p className="text-sm text-muted mt-2"> <Card className="mb-6">
Тонкая настройка поведения приложения <SelectRow
</p> 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 минут' }
]}
last
/>
</Card>
{/* Window */}
<SectionHeader title="Окно и трей" />
<Card className="mb-6">
<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}
last
/>
</Card>
{/* Appearance */}
<SectionHeader title="Внешний вид" />
<Card className="mb-6">
<SelectRow
label="Тема"
hint="Светлая / тёмная / как в системе"
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: 'Как в системе' },
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
]}
last
/>
</Card>
<SectionHeader title="Обновления" />
<UpdaterCard />
</div> </div>
<Section title="Напоминания" icon={<Bell size={14} />}>
<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="Окно и трей" icon={<Monitor size={14} />}>
<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="Внешний вид" icon={<Palette size={14} />}>
<SelectRow
label="Тема"
hint="Тёмная подходит к спортивной эстетике приложения"
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: 'Как в системе' },
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
]}
/>
</Section>
<UpdaterCard />
</div> </div>
) )
} }
function Section({
title,
icon,
children
}: {
title: string
icon: React.ReactNode
children: React.ReactNode
}): JSX.Element {
return (
<section className="mb-7">
<div className="flex items-center gap-2 mb-3">
<span className="w-7 h-7 rounded-lg bg-accent/15 text-accent grid place-items-center">
{icon}
</span>
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold">
{title}
</h2>
</div>
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
{children}
</div>
</section>
)
}
function ToggleRow({ function ToggleRow({
label, label,
hint, hint,
checked, checked,
onChange, onChange,
disabled disabled,
last = false
}: { }: {
label: string label: string
hint?: string hint?: string
checked: boolean checked: boolean
onChange: (v: boolean) => void onChange: (v: boolean) => void
disabled?: boolean disabled?: boolean
last?: boolean
}): JSX.Element { }): JSX.Element {
return ( return (
<div <Row last={last} className={disabled ? 'opacity-50' : ''}>
className={[ <div className="flex-1 min-w-0">
'flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors', <div className="text-[15px] font-semibold leading-tight">{label}</div>
disabled ? 'opacity-50' : 'hover:bg-accent/[0.03]' {hint && (
].join(' ')} <div className="text-[13px] text-text/65 mt-1 leading-snug">
> {hint}
<div className="flex-1"> </div>
<div className="font-display font-semibold text-sm tracking-wide"> )}
{label}
</div>
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
</div> </div>
<Switch checked={checked} onChange={onChange} disabled={disabled} /> <Switch checked={checked} onChange={onChange} disabled={disabled} />
</div> </Row>
) )
} }
@@ -167,26 +149,30 @@ function SelectRow({
hint, hint,
value, value,
onChange, onChange,
options options,
last = false
}: { }: {
label: string label: string
hint?: string hint?: string
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
options: { value: string; label: string }[] options: { value: string; label: string }[]
last?: boolean
}): JSX.Element { }): JSX.Element {
return ( return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors hover:bg-accent/[0.03]"> <Row last={last}>
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide"> <div className="text-[15px] font-semibold leading-tight">{label}</div>
{label} {hint && (
</div> <div className="text-[13px] text-text/65 mt-1 leading-snug">
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>} {hint}
</div>
)}
</div> </div>
<select <select
value={value} value={value}
onChange={(e) => onChange(e.target.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 focus:ring-2 focus:ring-accent/30 transition-all" className="h-9 pl-3 pr-8 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0"
> >
{options.map((o) => ( {options.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@@ -194,6 +180,6 @@ function SelectRow({
</option> </option>
))} ))}
</select> </select>
</div> </Row>
) )
} }

View File

@@ -2,39 +2,60 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ===== Design tokens — Apple HIG with Manrope/Fraunces ===== */
:root { :root {
/* Sport palette — Strava-inspired energy orange + intense rose pair */ /* Brand & semantic colors (iOS system palette) */
--accent: 249 115 22; /* orange-500 — primary energy / "GO" */ --accent: 255 107 53; /* Apple Fitness Move orange */
--accent-soft: 249 115 22; --accent-2: 255 45 85; /* systemPink */
--accent-2: 244 63 94; /* rose-500 — gradient pair, intensity */ --success: 52 199 89; /* systemGreen */
--victory: 132 204 22; /* lime-500 — done / personal best */ --warning: 255 159 10; /* systemOrange dark */
--defeat: 220 38 38; /* red-600 — danger */ --destructive: 255 59 48; /* systemRed */
--xp: 245 158 11; /* amber-500 — streak / XP */ --info: 0 122 255; /* systemBlue */
color-scheme: light dark; color-scheme: light dark;
} }
/* Light theme — clean athletic paper feel */ /* Light — polished iOS groupedBackground with warm undertone */
:root { :root {
--bg: 250 250 251; --bg: 245 245 249; /* slightly warmer than 242,242,247 */
--bg-deep: 240 240 244;
--surface: 255 255 255; --surface: 255 255 255;
--surface-elevated: 248 248 250; --surface-2: 240 240 245; /* subtle separation for inputs/sections */
--border: 226 228 234; --text: 17 17 19; /* not pure black — softer */
--text: 17 18 24; --text-secondary: 60 60 67;
--muted: 102 105 120; --text-tertiary: 60 60 67;
--hairline: 60 60 67;
--vibrancy: 255 255 255;
--accent-soft: 255 107 53;
--bg-deep: 232 232 238;
--surface-elevated: 255 255 255;
--border: 60 60 67;
--muted: 60 60 67;
--victory: 52 199 89;
--defeat: 255 59 48;
--xp: 255 159 10;
} }
/* Dark theme — warm graphite (not cool navy) */ /* Dark — true black with grey elevation */
.dark { .dark {
--bg: 13 14 18; --bg: 0 0 0;
--bg-deep: 7 8 11; --surface: 28 28 30;
--surface: 22 24 30; --surface-2: 44 44 46;
--surface-elevated: 30 32 40; --text: 255 255 255;
--border: 50 53 64; --text-secondary: 235 235 245;
--text: 236 238 244; --text-tertiary: 235 235 245;
--muted: 148 152 165; --hairline: 84 84 88;
--vibrancy: 28 28 30;
--bg-deep: 0 0 0;
--surface-elevated: 44 44 46;
--border: 84 84 88;
--muted: 235 235 245;
} }
/* ===== Base ===== */
html, html,
body, body,
#root { #root {
@@ -45,48 +66,59 @@ body,
} }
body { body {
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; font-family:
'Plus Jakarta Sans',
-apple-system,
'SF Pro Text',
'Segoe UI Variable Text',
'Segoe UI',
system-ui,
sans-serif;
background-color: rgb(var(--bg));
color: rgb(var(--text)); color: rgb(var(--text));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: rgb(var(--bg)); font-size: 14px;
background-image: line-height: 1.5;
radial-gradient( letter-spacing: -0.005em;
1200px 600px at 85% -10%,
rgb(var(--accent) / 0.12),
transparent 60%
),
radial-gradient(
900px 500px at -10% 110%,
rgb(var(--accent-2) / 0.1),
transparent 60%
);
background-attachment: fixed;
} }
.dark body { /* Display — Bricolage Grotesque for headings and brand. Variable opsz axis
background-image: gives bigger glyphs slightly more character. */
radial-gradient(
1200px 600px at 85% -10%,
rgb(var(--accent) / 0.18),
transparent 60%
),
radial-gradient(
900px 500px at -10% 110%,
rgb(var(--accent-2) / 0.14),
transparent 60%
),
linear-gradient(180deg, rgb(var(--bg-deep)) 0%, rgb(var(--bg)) 100%);
}
/* Display font for big numbers / sport headers */
.font-display { .font-display {
font-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif; font-family:
letter-spacing: 0.02em; 'Bricolage Grotesque',
'Plus Jakarta Sans',
-apple-system,
'SF Pro Display',
system-ui,
sans-serif;
font-optical-sizing: auto;
font-variation-settings: 'opsz' 24;
letter-spacing: -0.02em;
} }
/* Serif → repurposed for the "hero" big titles. Same Bricolage but with the
opsz axis pushed to 96 for distinctive display feel. */
.font-serif {
font-family:
'Bricolage Grotesque',
'Plus Jakarta Sans',
-apple-system,
'SF Pro Display',
system-ui,
sans-serif;
font-optical-sizing: auto;
font-variation-settings: 'opsz' 96;
letter-spacing: -0.035em;
}
.font-mono-num { .font-mono-num {
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace; font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code',
Menlo, monospace;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-feature-settings: 'ss02', 'ss19', 'zero';
letter-spacing: -0.01em;
} }
/* Custom titlebar drag region */ /* Custom titlebar drag region */
@@ -99,160 +131,84 @@ body {
app-region: no-drag; app-region: no-drag;
} }
/* iOS 0.5px-style hairlines */
.hairline-b {
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.18);
}
.hairline-t {
box-shadow: inset 0 0.5px 0 rgb(var(--hairline) / 0.18);
}
.dark .hairline-b,
.dark .hairline-t {
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.4);
}
/* macOS vibrancy */
.vibrancy {
background-color: rgb(var(--vibrancy) / 0.72);
backdrop-filter: saturate(180%) blur(30px);
-webkit-backdrop-filter: saturate(180%) blur(30px);
}
/* Soft iOS card shadow with subtle warmth */
.shadow-card {
box-shadow:
0 0.5px 0 rgb(0 0 0 / 0.03),
0 1px 2px rgb(15 23 42 / 0.04),
0 6px 14px -4px rgb(15 23 42 / 0.05);
}
.dark .shadow-card {
box-shadow:
0 0.5px 0 rgb(255 255 255 / 0.04) inset,
0 1px 2px rgb(0 0 0 / 0.4);
}
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 8px;
height: 10px; height: 8px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgb(var(--border)); background: rgb(var(--text-tertiary) / 0.3);
border-radius: 8px; border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgb(var(--accent) / 0.4); background: rgb(var(--text-secondary) / 0.45);
background-clip: padding-box;
border: 2px solid transparent;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Selection */
::selection { ::selection {
background: rgb(var(--accent) / 0.4); background: rgb(var(--accent) / 0.25);
color: rgb(var(--text)); }
*:focus-visible {
outline: 2px solid rgb(var(--accent) / 0.55);
outline-offset: 2px;
} }
/* Reminder-window root: neon HUD frame */
.reminder-shell { .reminder-shell {
position: relative; position: relative;
border: 1px solid rgb(var(--accent) / 0.5); border: 0.5px solid rgb(var(--hairline) / 0.25);
border-radius: 20px; border-radius: 22px;
background: background: rgb(var(--surface));
radial-gradient(
circle at 50% -20%,
rgb(var(--accent) / 0.22),
transparent 60%
),
linear-gradient(180deg, rgb(var(--surface-elevated)) 0%, rgb(var(--surface)) 100%);
box-shadow: box-shadow:
0 0 0 1px rgb(var(--accent) / 0.15), 0 1px 2px rgb(0 0 0 / 0.06),
0 20px 80px -20px rgb(var(--accent) / 0.45), 0 20px 50px -16px rgb(0 0 0 / 0.4);
0 24px 60px -20px rgb(0 0 0 / 0.6);
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
} }
/* Soft scanline texture for HUD surfaces */ .text-secondary {
.hud-scanlines { color: rgb(var(--text-secondary) / 0.6);
background-image: repeating-linear-gradient(
180deg,
rgb(var(--text) / 0.03) 0px,
rgb(var(--text) / 0.03) 1px,
transparent 1px,
transparent 3px
);
} }
.text-tertiary {
/* Gradient text and gradient brand */ color: rgb(var(--text-tertiary) / 0.3);
.text-gradient-brand {
background-image: linear-gradient(
135deg,
rgb(var(--accent)) 0%,
rgb(var(--accent-2)) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.bg-gradient-brand { .dark .text-secondary {
background-image: linear-gradient( color: rgb(var(--text-secondary) / 0.6);
135deg,
rgb(var(--accent)) 0%,
rgb(var(--accent-2)) 100%
);
} }
.bg-gradient-victory { .dark .text-tertiary {
background-image: linear-gradient( color: rgb(var(--text-tertiary) / 0.3);
135deg,
rgb(var(--victory)) 0%,
rgb(var(--accent)) 100%
);
}
/* Neon border (animated gradient stroke for "due" / "active" cards) */
.neon-border {
position: relative;
isolation: isolate;
}
.neon-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
rgb(var(--accent)) 0%,
rgb(var(--accent-2)) 60%,
rgb(var(--accent)) 100%
);
background-size: 200% 200%;
animation: neon-shift 6s linear infinite;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 1;
}
/* HUD pulse — soft outer glow for "due" cards */
.hud-pulse {
animation: hud-pulse 2.4s ease-in-out infinite;
}
@keyframes neon-shift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
@keyframes hud-pulse {
0%,
100% {
box-shadow:
0 0 0 0 rgb(var(--accent) / 0.45),
0 12px 30px -10px rgb(var(--accent) / 0.4);
}
50% {
box-shadow:
0 0 0 6px rgb(var(--accent) / 0),
0 18px 40px -10px rgb(var(--accent) / 0.55);
}
}
/* Subtle dot-grid texture (sidebar / hero strip) */
.dot-grid {
background-image: radial-gradient(
rgb(var(--text) / 0.07) 1px,
transparent 1px
);
background-size: 14px 14px;
}
/* Cooldown ring SVG helpers */
.cooldown-track {
stroke: rgb(var(--border));
}
.cooldown-fill {
stroke: url(#cooldownGrad);
stroke-linecap: round;
filter: drop-shadow(0 0 6px rgb(var(--accent) / 0.6));
transition: stroke-dashoffset 0.5s linear;
} }

View File

@@ -121,7 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
startWithWindows: false, startWithWindows: false,
minimizeToTray: true, minimizeToTray: true,
startMinimized: false, startMinimized: false,
theme: 'system', theme: 'light',
snoozeMinutes: 5 snoozeMinutes: 5
} }

View File

@@ -5,60 +5,78 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// iOS semantic palette
accent: 'rgb(var(--accent) / <alpha-value>)', accent: 'rgb(var(--accent) / <alpha-value>)',
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)', 'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)', 'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
victory: 'rgb(var(--victory) / <alpha-value>)', success: 'rgb(var(--success) / <alpha-value>)',
defeat: 'rgb(var(--defeat) / <alpha-value>)', warning: 'rgb(var(--warning) / <alpha-value>)',
xp: 'rgb(var(--xp) / <alpha-value>)', destructive: 'rgb(var(--destructive) / <alpha-value>)',
info: 'rgb(var(--info) / <alpha-value>)',
// Surfaces
bg: 'rgb(var(--bg) / <alpha-value>)', bg: 'rgb(var(--bg) / <alpha-value>)',
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)', 'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
surface: 'rgb(var(--surface) / <alpha-value>)', surface: 'rgb(var(--surface) / <alpha-value>)',
'surface-2': 'rgb(var(--surface-2) / <alpha-value>)',
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)', 'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
// Text & lines
text: 'rgb(var(--text) / <alpha-value>)', text: 'rgb(var(--text) / <alpha-value>)',
muted: 'rgb(var(--muted) / <alpha-value>)' muted: 'rgb(var(--muted) / <alpha-value>)',
hairline: 'rgb(var(--hairline) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
// Legacy aliases (so unchanged pages still compile)
victory: 'rgb(var(--victory) / <alpha-value>)',
defeat: 'rgb(var(--defeat) / <alpha-value>)',
xp: 'rgb(var(--xp) / <alpha-value>)'
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'Segoe UI Variable', 'Segoe UI', 'system-ui', 'sans-serif'], sans: [
display: ['Rajdhani', 'Inter', 'Segoe UI Variable', 'sans-serif'], 'Plus Jakarta Sans',
mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'Menlo', 'monospace'] '-apple-system',
'SF Pro Text',
'Segoe UI Variable Text',
'Segoe UI',
'system-ui',
'sans-serif'
],
display: [
'Bricolage Grotesque',
'Plus Jakarta Sans',
'-apple-system',
'SF Pro Display',
'system-ui',
'sans-serif'
],
serif: [
'Bricolage Grotesque',
'Plus Jakarta Sans',
'-apple-system',
'SF Pro Display',
'system-ui',
'sans-serif'
],
mono: [
'JetBrains Mono',
'ui-monospace',
'SF Mono',
'Cascadia Code',
'Menlo',
'monospace'
]
},
borderRadius: {
// iOS-specific radii
xl: '14px',
'2xl': '18px',
'3xl': '22px'
}, },
boxShadow: { boxShadow: {
soft: '0 8px 30px -12px rgb(0 0 0 / 0.35)', ios: '0 0.5px 0 rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04)',
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.55)', sheet:
'glow-lg': '0 1px 2px rgb(0 0 0 / 0.06), 0 20px 50px -16px rgb(0 0 0 / 0.4)'
'0 0 0 1px rgb(var(--accent) / 0.45), 0 18px 48px -12px rgb(var(--accent) / 0.7)',
'glow-victory':
'0 0 0 1px rgb(var(--victory) / 0.45), 0 12px 32px -10px rgb(var(--victory) / 0.55)',
hud: '0 1px 0 rgb(var(--text) / 0.04) inset, 0 0 0 1px rgb(var(--border) / 0.8), 0 18px 40px -20px rgb(0 0 0 / 0.4)'
},
backgroundImage: {
'gradient-brand':
'linear-gradient(135deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 100%)',
'gradient-victory':
'linear-gradient(135deg, rgb(var(--victory)) 0%, rgb(var(--accent)) 100%)',
'gradient-defeat':
'linear-gradient(135deg, rgb(var(--defeat)) 0%, rgb(var(--accent-2)) 100%)'
},
animation: {
'pulse-ring': 'pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
shimmer: 'shimmer 2.5s linear infinite',
'neon-shift': 'neon-shift 6s linear infinite'
},
keyframes: {
'pulse-ring': {
'0%, 100%': { transform: 'scale(1)', opacity: '0.7' },
'50%': { transform: 'scale(1.1)', opacity: '0.25' }
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' }
},
'neon-shift': {
'0%': { backgroundPosition: '0% 50%' },
'100%': { backgroundPosition: '200% 50%' }
}
} }
} }
}, },