3 Commits

Author SHA1 Message Date
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
22 changed files with 1683 additions and 1619 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "laude",
"version": "0.3.0",
"version": "0.3.1",
"description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js",
"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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;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=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import Dashboard from './pages/Dashboard'
@@ -32,9 +33,9 @@ export default function App(): JSX.Element {
/>
<main className="flex-1 overflow-hidden min-w-0">
{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>
</div>
@@ -43,20 +44,31 @@ export default function App(): JSX.Element {
)
}
// Close mobile drawer whenever the route changes.
function RoutesWithCloseOnNav({ onClose }: { onClose: () => void }): JSX.Element {
function RoutedPages({ onNav }: { onNav: () => void }): JSX.Element {
const location = useLocation()
useEffect(() => {
onClose()
onNav()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/exercises" element={<Exercises />} />
<Route path="/games" element={<GamesPage />} />
<Route path="/challenges" element={<ChallengesPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
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 { motion } from 'framer-motion'
import {
Check,
Clock,
X,
Trophy,
Skull,
Gamepad2,
Flame,
Zap
} from 'lucide-react'
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
import type {
Exercise,
MatchSummary,
Settings,
ChallengeResult
} from '@shared/types'
import { Icon } from './lib/icon'
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(() => {
if (mode.kind !== 'exercise') return
const ex = mode.exercise
@@ -120,101 +116,71 @@ function ExerciseReminder({
}
return (
<div className="reminder-shell flex flex-col h-full hud-scanlines">
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
<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>
<div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-8 px-2 flex items-center justify-end">
<button
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="Закрыть"
>
<X size={13} />
<X size={13} strokeWidth={2.5} />
</button>
</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
initial={{ scale: 0.6, opacity: 0, rotate: -8 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 200, damping: 16 }}
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="relative mb-6"
>
{/* Outer rotating ring */}
<motion.div
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 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)]">
<Icon name={exercise.icon} size={44} strokeWidth={2} />
</div>
</motion.div>
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold">
Двигайся
<div className="text-[12px] uppercase tracking-[0.18em] text-text/45 font-medium">
Время тренировки
</div>
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide">
<h1 className="font-serif text-[32px] leading-tight tracking-tight mt-2 mb-3">
{exercise.name}
</h1>
{/* HUD reps counter */}
<div className="inline-flex items-baseline gap-2 px-5 py-2 rounded-2xl border border-accent/30 bg-accent/10 shadow-glow">
<Zap size={16} className="text-xp" />
<span className="font-mono-num font-bold text-5xl text-gradient-brand leading-none">
<div className="inline-flex items-baseline gap-2 font-mono-num">
<span className="text-[64px] font-semibold tracking-tight text-text leading-none">
{exercise.reps}
</span>
<span className="text-xs font-display font-semibold text-muted uppercase tracking-widest">
REPS
</span>
<span className="text-[15px] text-text/55">раз</span>
</div>
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
<Clock size={10} />
<div className="text-[12px] text-text/45 mt-4 inline-flex items-center gap-1.5">
<Clock size={11} strokeWidth={2.4} />
Следующее через {formatInterval(exercise.intervalMinutes)}
</div>
</div>
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
<button
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>
{/* iOS action sheet — buttons stacked vertically, equal width */}
<div className="px-4 pb-4 space-y-2">
<button
onClick={done}
title="Enter"
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"
className="w-full h-12 rounded-2xl bg-accent text-white text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<span className="inline-flex items-center gap-1.5">
<Check size={16} /> Сделал
</span>
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
<Check size={16} strokeWidth={2.5} /> Готово
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={snooze}
className="h-11 rounded-2xl bg-surface-2 text-text text-[14px] font-medium inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Clock size={14} strokeWidth={2.5} /> {snoozeMinutes} мин
</button>
<button
onClick={skip}
className="h-11 rounded-2xl bg-surface-2 text-text/55 text-[14px] font-medium inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
Пропустить
</button>
</div>
</div>
</div>
)
@@ -239,71 +205,59 @@ function MatchSummaryView({
const won = summary.won === true
const lost = summary.won === false
const heroGradient = won
? 'bg-gradient-victory'
: lost
? 'bg-gradient-defeat'
: 'bg-gradient-brand'
return (
<div className="reminder-shell flex flex-col h-full hud-scanlines">
<div className="titlebar-drag h-9 px-3 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">
<Gamepad2 size={12} /> {summary.gameName}
<div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-9 px-2 flex items-center justify-between">
<div className="text-[11px] text-text/45 font-medium inline-flex items-center gap-1.5 px-2">
<Gamepad2 size={11} strokeWidth={2.4} /> {summary.gameName}
</div>
<button
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="Закрыть"
>
<X size={13} />
<X size={13} strokeWidth={2.5} />
</button>
</div>
<div className="px-6 pt-2 pb-4 text-center">
<div className="px-5 pt-1 pb-4 text-center">
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3"
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
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`} />
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}>
{won ? (
<Trophy size={30} />
) : lost ? (
<Skull size={30} />
) : (
<Gamepad2 size={30} />
)}
</div>
{won ? (
<Trophy size={26} strokeWidth={2} />
) : lost ? (
<Frown size={26} strokeWidth={2} />
) : (
<Gamepad2 size={26} strokeWidth={2} />
)}
</motion.div>
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
{won
? 'Победа · тренировка заработана'
: lost
? 'Проигрыш · но тело не сдаётся'
: 'Матч завершён'}
<h1 className="font-serif text-[22px] tracking-tight">
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
</h1>
<p className="text-[11px] text-muted mt-1.5">
<span className="font-mono-num font-semibold text-text">
<p className="text-[12px] text-text/45 mt-1">
<span className="font-mono-num font-medium text-text/65">
{Math.floor(summary.durationMs / 60_000)}
</span>{' '}
мин · {summary.results.length}{' '}
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
мин · {summary.results.length} челлендж
{summary.results.length === 1 ? '' : 'а'} ·{' '}
{allDone ? (
<span className="text-victory font-semibold uppercase tracking-wider">
готово
</span>
<span className="text-success font-medium">всё готово</span>
) : (
<span className="text-accent font-bold font-mono-num">
<span className="text-accent font-mono-num font-semibold">
{remainingReps} осталось
</span>
)}
</p>
</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) => (
<ChallengeRow
key={r.challengeId}
@@ -314,10 +268,10 @@ function MatchSummaryView({
))}
</div>
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40">
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold">
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
<div className="flex-1 text-[12px] text-text/55">
Всего ·{' '}
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
<span className="text-text font-mono-num font-semibold text-[14px]">
{totalReps}
</span>{' '}
повторов
@@ -325,13 +279,13 @@ function MatchSummaryView({
<button
onClick={onClose}
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',
allDone ? 'bg-gradient-victory shadow-glow-victory' : 'bg-gradient-brand shadow-glow'
'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-success' : 'bg-accent'
].join(' ')}
>
{allDone ? (
<>
<Check size={16} /> Готово
<Check size={14} strokeWidth={2.5} /> Закрыть
</>
) : (
'Позже'
@@ -357,41 +311,38 @@ function ChallengeRow({
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
className={[
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
done
? 'border-victory/40 bg-victory/10'
: 'border-border/70 bg-surface-elevated/60'
'flex items-center gap-3 rounded-2xl p-3 transition-colors',
done ? 'bg-success/10' : 'bg-surface-2'
].join(' ')}
>
<div
className={[
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
done ? 'bg-victory/20 text-victory' : 'bg-accent/15 text-accent'
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
done ? 'bg-success text-white' : 'bg-accent text-white'
].join(' ')}
>
<Icon name={result.icon} size={22} />
<Icon name={result.icon} size={19} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div
className={[
'font-display font-semibold tracking-wide truncate',
done ? 'line-through opacity-60' : ''
'text-[14px] font-medium truncate',
done ? 'line-through opacity-55' : ''
].join(' ')}
>
{result.exerciseName}
</div>
<div className="text-[11px] text-muted mt-0.5">
<span className="font-mono-num font-bold text-text">
<div className="text-[12px] text-text/55 mt-0.5">
<span className="font-mono-num font-semibold text-text/75">
{result.statValue}
</span>{' '}
{result.statLabel} {' '}
<span className="text-accent">{result.name}</span>
{result.statLabel} <span>{result.name}</span>
</div>
</div>
<div
className={[
'font-mono-num text-2xl font-bold tabular-nums',
done ? 'text-victory' : 'text-gradient-brand'
'font-mono-num text-[20px] font-semibold tracking-tight',
done ? 'text-success' : 'text-accent'
].join(' ')}
>
{result.reps}
@@ -400,14 +351,14 @@ function ChallengeRow({
onClick={onMarkDone}
disabled={done}
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
? 'bg-victory text-white cursor-default'
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow'
? 'bg-success text-white cursor-default'
: 'bg-accent text-white active:scale-90'
].join(' ')}
aria-label="Готово"
>
<Check size={16} />
<Check size={15} strokeWidth={2.5} />
</button>
</motion.div>
)

View File

@@ -1,5 +1,6 @@
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 { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format'
@@ -14,6 +15,11 @@ type Props = {
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({
exercise,
tick,
@@ -27,8 +33,9 @@ export function ExerciseCard({
const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled
const [menuOpen, setMenuOpen] = useState(false)
// SVG cooldown ring math
// Ring math
const R = 22
const C = 2 * Math.PI * R
const dashOffset = C * (1 - elapsedPct)
@@ -36,145 +43,140 @@ export function ExerciseCard({
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
whileHover={{ y: -2 }}
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
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(' ')}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
className="relative bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"
>
{/* Glow corner accent */}
<div
className={[
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity',
exercise.enabled
? 'bg-accent/15 opacity-100'
: 'bg-muted/10 opacity-50'
].join(' ')}
/>
<div className="relative flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Hex-like rounded icon plaque with cooldown ring */}
<div className="relative w-14 h-14 shrink-0">
<svg
className="absolute inset-0 -rotate-90"
viewBox="0 0 56 56"
width="56"
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>
<div className="flex items-start gap-4">
{/* Icon + progress ring */}
<div className="relative w-14 h-14 shrink-0">
<svg
className="absolute inset-0 -rotate-90"
viewBox="0 0 56 56"
width="56"
height="56"
>
<circle
cx="28"
cy="28"
r={R}
fill="none"
strokeWidth="2.5"
className="stroke-hairline/15 dark:stroke-hairline/30"
/>
{exercise.enabled && (
<circle
className="cooldown-track"
cx="28"
cy="28"
r={R}
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
className="cooldown-fill"
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
)}
</svg>
<div
className={[
'text-sm font-mono-num font-bold',
isDue ? 'text-accent' : 'text-text'
'absolute inset-[8px] rounded-full grid place-items-center',
exercise.enabled
? 'bg-accent/10 text-accent'
: 'bg-surface-2 text-text/40'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
</span>
<Icon name={exercise.icon} size={20} />
</div>
</div>
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
<motion.div
className={[
'h-full rounded-full',
isDue ? 'bg-gradient-brand' : 'bg-accent'
].join(' ')}
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }}
transition={{ duration: 0.5, ease: 'linear' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-display text-[17px] font-semibold leading-tight truncate">
{exercise.name}
</h3>
<div className="relative">
<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-[13px] text-text/55 mt-0.5">
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
</div>
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[11px] text-text/45 uppercase tracking-wider font-medium">
{isDue ? 'Сейчас' : 'Через'}
</div>
<div
className={[
'font-mono-num text-[22px] font-semibold leading-none mt-0.5 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 className="relative flex items-center gap-2 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<button
{/* Done action — appears as filled pill at bottom only on due */}
{isDue && (
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
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-10 rounded-xl bg-accent text-white text-[14px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={14} /> Сделал
</button>
<button
onClick={onEdit}
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text 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>
<Check size={15} strokeWidth={2.5} /> Готово
</motion.button>
)}
</motion.div>
)
}

View File

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

View File

@@ -6,16 +6,35 @@ import {
Gamepad2,
Target,
Settings as SettingsIcon,
Dumbbell,
X
} from 'lucide-react'
const links = [
{ to: '/', label: 'Дашборд', icon: LayoutDashboard, end: true },
{ to: '/exercises', label: 'Упражнения', icon: ListChecks },
{ to: '/games', label: 'Игры', icon: Gamepad2 },
{ to: '/challenges', label: 'Челленджи', icon: Target },
{ to: '/settings', label: 'Настройки', icon: SettingsIcon }
type Item = {
to: string
label: string
icon: typeof LayoutDashboard
end?: boolean
tint?: string
}
// Each item gets a tinted icon-square reminiscent of iOS Settings rows.
const items: Item[] = [
{
to: '/',
label: 'Сегодня',
icon: LayoutDashboard,
end: true,
tint: 'bg-accent'
},
{ to: '/exercises', label: 'Упражнения', icon: ListChecks, tint: 'bg-info' },
{ to: '/games', label: 'Игры', icon: Gamepad2, tint: 'bg-accent-2' },
{ to: '/challenges', label: 'Челленджи', icon: Target, tint: 'bg-warning' },
{
to: '/settings',
label: 'Настройки',
icon: SettingsIcon,
tint: 'bg-text/70'
}
]
type Props = {
@@ -23,12 +42,15 @@ type Props = {
onMobileClose?: () => void
}
export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Element {
export function Sidebar({
mobileOpen = false,
onMobileClose
}: Props): JSX.Element {
return (
<>
{/* Desktop sidebar: hidden on <md, icon-only on md, full on lg+ */}
<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">
<SidebarContent compact />
{/* Desktop sidebar — macOS vibrancy panel */}
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
<SidebarContent />
</aside>
{/* Mobile drawer */}
@@ -39,29 +61,30 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<motion.div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-black/30 backdrop-blur-md"
onClick={onMobileClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
<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%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
transition={{ type: 'spring', stiffness: 420, damping: 38 }}
>
<button
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"
aria-label="Закрыть меню"
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="Закрыть"
>
<X size={16} />
<X size={14} strokeWidth={2.5} />
</button>
<SidebarContent compact={false} onNav={onMobileClose} />
<SidebarContent onNav={onMobileClose} />
</motion.aside>
</motion.div>
)}
@@ -70,84 +93,52 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
)
}
function SidebarContent({
compact,
onNav
}: {
compact: boolean
onNav?: () => void
}): JSX.Element {
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
return (
<>
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
{/* Brand */}
<div className="relative px-3 lg:px-5 py-5">
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
<div className="relative w-11 h-11 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
<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 className="px-5 pt-7 pb-6">
<div className="font-serif text-[28px] leading-none tracking-tight">
Laude
</div>
<div className="text-[12px] text-text/45 mt-1.5">
Move with intention
</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 className="relative px-2 lg:px-3 flex flex-col gap-0.5">
{links.map(({ to, label, icon: Icon, end }) => (
<nav className="px-2.5 flex flex-col gap-0.5">
{items.map(({ to, label, icon: Icon, end, tint }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={onNav}
title={compact ? label : undefined}
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',
compact ? 'justify-center lg:justify-start' : '',
'flex items-center gap-3 px-2.5 py-2 rounded-xl transition-colors duration-150',
isActive
? 'text-text bg-surface-elevated/80'
: 'text-muted hover:text-text hover:bg-surface-elevated/50'
? 'bg-text/[0.06] dark:bg-white/[0.08]'
: 'hover:bg-text/[0.04] dark:hover:bg-white/[0.04]'
].join(' ')
}
>
{({ isActive }) => (
<>
<span
<div
className={[
'absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all',
isActive
? 'bg-gradient-to-b from-accent to-accent-2 opacity-100 shadow-glow'
: 'opacity-0'
'w-7 h-7 rounded-lg grid place-items-center text-white shrink-0',
tint ?? 'bg-text/70'
].join(' ')}
/>
<Icon
size={18}
className={isActive ? 'text-accent' : ''}
strokeWidth={isActive ? 2.4 : 2}
/>
>
<Icon size={15} strokeWidth={2.4} />
</div>
<span
className={[
compact ? 'hidden lg:inline' : 'inline',
isActive ? 'font-semibold' : ''
'text-[14px] truncate',
isActive
? 'text-text font-semibold'
: 'text-text/85 font-medium'
].join(' ')}
>
{label}
@@ -158,26 +149,14 @@ function SidebarContent({
))}
</nav>
{/* Status footer — hidden on icon-only desktop */}
<div
className={[
'relative mt-auto p-4',
compact ? 'hidden lg:block' : 'block'
].join(' ')}
>
<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>
{/* Status footer */}
<div className="mt-auto px-5 pb-5">
<div className="flex items-center gap-2 text-[11px] text-text/45">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Активность отслеживается
</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 = {
title: string
onMenuClick?: () => void
}
/**
* macOS-style translucent titlebar. Title centred small, no app icon.
* Window buttons sit right; a left-side hamburger surfaces on mobile only.
*/
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
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="flex items-center gap-2">
{/* Mobile menu — only visible on <md (where sidebar is hidden) */}
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
{/* Left: hamburger only on small */}
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && (
<button
onClick={onMenuClick}
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text transition-colors"
aria-label="Открыть меню"
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 transition-colors"
aria-label="Меню"
>
<Menu size={15} />
<Menu size={15} strokeWidth={2} />
</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 className="titlebar-nodrag flex items-center gap-1">
<button
onClick={() => window.api.minimizeMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
aria-label="Свернуть"
>
<Minus size={14} />
</button>
<button
onClick={() => window.api.hideMain()}
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
aria-label="Скрыть в трей"
>
<Square size={12} />
</button>
<button
{/* Centre title */}
<div className="text-[12px] font-medium text-text/55 truncate px-2">
{title}
</div>
{/* Right window controls */}
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
<Minus size={13} strokeWidth={2} />
</WinBtn>
<WinBtn onClick={() => window.api.hideMain()} label="В трей">
<Square size={11} strokeWidth={2} />
</WinBtn>
<WinBtn
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"
aria-label="Закрыть"
label="Закрыть"
danger
>
<X size={14} />
</button>
<X size={13} strokeWidth={2} />
</WinBtn>
</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'
import { motion } from 'framer-motion'
import { Button } from './ui/Button'
import { Card } from './ui/Card'
import type { UpdaterStatus } from '@shared/types'
export function UpdaterCard(): JSX.Element {
@@ -28,7 +29,6 @@ export function UpdaterCard(): JSX.Element {
setBusy(false)
}
}
async function download(): Promise<void> {
setBusy(true)
try {
@@ -37,25 +37,20 @@ export function UpdaterCard(): JSX.Element {
setBusy(false)
}
}
function install(): void {
void window.api.updaterInstall()
}
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">
<PackageCheck size={14} />
</span>
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold">
Обновления
</h2>
</div>
<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>
<Card>
<Body
status={status}
busy={busy}
onCheck={check}
onDownload={download}
onInstall={install}
/>
</Card>
)
}
@@ -74,9 +69,9 @@ function Body({
}): JSX.Element {
if (status.kind === 'unsupported') {
return (
<Row
<Cell
tone="muted"
icon={<AlertTriangle size={18} />}
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Auto-update недоступен"
subtitle={status.reason}
/>
@@ -84,23 +79,23 @@ function Body({
}
if (status.kind === 'checking') {
return (
<Row
tone="accent"
icon={<RefreshCw size={18} className="animate-spin" />}
title="Проверяем наличие обновлений…"
<Cell
tone="info"
icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
title="Проверяем обновления…"
/>
)
}
if (status.kind === 'not-available') {
return (
<Row
tone="victory"
icon={<CheckCircle2 size={18} />}
title="Установлена последняя версия"
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title="Последняя версия"
subtitle={`Текущая: v${status.currentVersion}`}
action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
</Button>
}
/>
@@ -108,14 +103,18 @@ function Body({
}
if (status.kind === 'available') {
return (
<Row
<Cell
tone="accent"
icon={<Sparkles size={18} />}
title={`Доступно обновление v${status.version}`}
subtitle={status.releaseDate ? new Date(status.releaseDate).toLocaleString('ru-RU') : undefined}
icon={<Sparkles size={16} strokeWidth={2.4} />}
title={`Доступна v${status.version}`}
subtitle={
status.releaseDate
? new Date(status.releaseDate).toLocaleString('ru-RU')
: undefined
}
action={
<Button size="sm" onClick={onDownload} disabled={busy}>
<Download size={14} /> Скачать
<Download size={13} strokeWidth={2.5} /> Скачать
</Button>
}
/>
@@ -125,27 +124,27 @@ function Body({
const pct = Math.max(0, Math.min(100, status.percent || 0))
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
return (
<div className="px-5 py-4">
<div className="px-4 py-4">
<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">
<Download size={18} />
<div className="w-9 h-9 rounded-xl bg-accent/12 text-accent grid place-items-center">
<Download size={16} strokeWidth={2.4} />
</div>
<div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide">
Загружаем обновление
<div className="text-[15px] font-medium leading-tight">
Загружаем обновление
</div>
<div className="text-xs text-muted mt-0.5 font-mono-num">
<div className="text-[12px] text-text/55 mt-0.5 font-mono-num">
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
</div>
</div>
<div className="font-mono-num font-bold text-lg text-accent">
<div className="font-mono-num font-semibold text-[17px] text-accent">
{pct.toFixed(0)}%
</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
className="h-full bg-gradient-brand"
className="h-full bg-accent"
animate={{ width: `${pct}%` }}
transition={{ duration: 0.3, ease: 'linear' }}
/>
@@ -155,13 +154,13 @@ function Body({
}
if (status.kind === 'downloaded') {
return (
<Row
tone="victory"
icon={<CheckCircle2 size={18} />}
title={`Готово · v${status.version} загружена`}
subtitle="Перезапустите приложение для применения"
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={`Готово · v${status.version}`}
subtitle="Перезапусти для применения"
action={
<Button variant="victory" size="sm" onClick={onInstall}>
<Button variant="filled" size="sm" onClick={onInstall}>
Перезапустить
</Button>
}
@@ -170,70 +169,70 @@ function Body({
}
if (status.kind === 'error') {
return (
<Row
tone="defeat"
icon={<AlertTriangle size={18} />}
title="Ошибка проверки обновлений"
<Cell
tone="destructive"
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Ошибка проверки"
subtitle={status.message}
action={
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Повторить
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Повторить
</Button>
}
/>
)
}
// idle
return (
<Row
<Cell
tone="muted"
icon={<PackageCheck size={18} />}
title="Проверить наличие обновлений"
icon={<PackageCheck size={16} strokeWidth={2.4} />}
title="Проверить обновления"
subtitle="Авто-проверка раз в 6 часов"
action={
<Button size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={14} /> Проверить
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
</Button>
}
/>
)
}
function Row({
function Cell({
tone,
icon,
title,
subtitle,
action
}: {
tone: 'accent' | 'victory' | 'defeat' | 'muted'
tone: 'accent' | 'info' | 'success' | 'destructive' | 'muted'
icon: React.ReactNode
title: string
subtitle?: string
action?: React.ReactNode
}): JSX.Element {
const toneClasses = {
accent: 'bg-accent/15 text-accent',
victory: 'bg-victory/15 text-victory',
defeat: 'bg-defeat/15 text-defeat',
muted: 'bg-surface-elevated text-muted'
const cls = {
accent: 'bg-accent/12 text-accent',
info: 'bg-info/12 text-info',
success: 'bg-success/15 text-success',
destructive: 'bg-destructive/12 text-destructive',
muted: 'bg-surface-2 text-text/55'
}[tone]
return (
<div className="flex items-center gap-4 px-5 py-4">
<div className="flex items-center gap-3 px-4 py-3.5">
<div
className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses
'w-9 h-9 rounded-xl grid place-items-center shrink-0',
cls
].join(' ')}
>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-display font-semibold text-sm tracking-wide">
{title}
</div>
<div className="text-[15px] font-medium leading-tight">{title}</div>
{subtitle && (
<div className="text-xs text-muted mt-0.5 truncate">{subtitle}</div>
<div className="text-[12px] text-text/55 mt-0.5 truncate">
{subtitle}
</div>
)}
</div>
{action}

View File

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

View File

@@ -0,0 +1,85 @@
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">
<div>
<div className="text-[12px] font-medium uppercase tracking-[0.06em] text-text/45">
{title}
</div>
{hint && <div className="text-[12px] text-text/45 mt-0.5">{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'
}
/**
* iOS-style centred sheet. Spring-snap on enter, soft fade-out.
* Backdrop uses heavy blur for proper iOS modal feel.
*/
export function Modal({
open,
onClose,
@@ -38,59 +42,48 @@ export function Modal({
<AnimatePresence>
{open && (
<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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
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]
].join(' ')}
style={{
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 }}
initial={{ scale: 0.94, y: 24, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.96, y: 8, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 26 }}
exit={{ scale: 0.96, y: 12, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
onClick={(e) => e.stopPropagation()}
>
{/* Glow accent corner */}
<div className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-56 h-56 rounded-full bg-accent-2/15 blur-3xl pointer-events-none" />
<div className="relative flex items-center justify-between px-5 py-4">
<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>
{/* Header — iOS large modal title */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="font-display text-[20px] font-semibold tracking-tight">
{title}
</h2>
<button
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="Закрыть"
>
<X size={16} />
<X size={14} strokeWidth={2.5} />
</button>
</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}
</div>
{footer && (
<>
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
<div className="relative px-5 py-4 flex justify-end gap-2">{footer}</div>
</>
<div className="hairline-t px-5 py-3 flex justify-end gap-2 bg-surface">
{footer}
</div>
)}
</motion.div>
</motion.div>

View File

@@ -1,3 +1,5 @@
import { motion } from 'framer-motion'
type Props = {
checked: boolean
onChange: (next: boolean) => void
@@ -5,7 +7,15 @@ type Props = {
'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 (
<button
type="button"
@@ -15,19 +25,20 @@ export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Ele
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
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',
checked
? 'bg-gradient-brand shadow-glow'
: 'bg-surface-elevated border border-border',
disabled ? 'opacity-50 cursor-not-allowed' : ''
'relative inline-flex h-[31px] w-[51px] shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-out',
checked ? 'bg-success' : 'bg-hairline/25 dark:bg-hairline/50',
disabled ? 'opacity-40 cursor-not-allowed' : ''
].join(' ')}
style={{ padding: 2 }}
>
<span
className={[
'inline-block h-5 w-5 transform rounded-full bg-white shadow-soft transition-transform duration-200',
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
'mt-0.5'
].join(' ')}
<motion.span
className="block h-[27px] w-[27px] rounded-full bg-white"
style={{
boxShadow:
'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)'
}}
animate={{ x: checked ? 20 : 0 }}
transition={{ type: 'spring', stiffness: 700, damping: 35 }}
/>
</button>
)

View File

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

View File

@@ -1,21 +1,12 @@
import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import {
Plus,
Pause,
Play,
Timer,
Flame,
Activity,
Gamepad2,
Trophy
} from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types'
import { formatCountdown, formatInterval } from '../lib/format'
import { formatCountdown } from '../lib/format'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
@@ -25,7 +16,6 @@ export default function Dashboard(): JSX.Element {
const exercises = state?.exercises ?? []
const settings = state?.settings
const challenges = state?.challenges ?? []
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
const stats = useMemo(() => {
@@ -33,32 +23,24 @@ export default function Dashboard(): JSX.Element {
const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
.sort((a, b) => a.ms - b.ms)[0]
const totalReps = enabled.reduce((s, e) => s + e.reps, 0)
const avgInterval =
enabled.length > 0
? Math.round(
enabled.reduce((s, e) => s + e.intervalMinutes, 0) / enabled.length
)
: 0
return {
total: exercises.length,
active: enabled.length,
nextMs: next?.ms ?? Infinity,
totalReps,
avgInterval
totalReps: enabled.reduce((s, e) => s + e.reps, 0)
}
}, [exercises, ticks])
const paused = !settings?.globalEnabled
function openCreate(): void {
setEditing(null)
setEditorOpen(true)
}
function openEdit(ex: Exercise): void {
setEditing(ex)
setEditorOpen(true)
}
async function handleSave(draft: {
name: string
reps: number
@@ -66,221 +48,197 @@ export default function Dashboard(): JSX.Element {
intervalMinutes: number
enabled: boolean
}): Promise<void> {
if (editing) {
await window.api.updateExercise(editing.id, draft)
} else {
await window.api.addExercise(draft)
}
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)
setEditorOpen(false)
}
async function handleDelete(id: string): Promise<void> {
await window.api.deleteExercise(id)
}
async function togglePause(): Promise<void> {
if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
const paused = !settings?.globalEnabled
const today = new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
{/* Hero header */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Тренировка дня
<div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
{/* Hero — iOS Large Title */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="min-w-0">
<div className="text-[13px] text-text/45 font-medium capitalize">
{today}
</div>
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
Сегодня
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="tinted" onClick={togglePause}>
{!paused ? (
<>
<Pause size={14} strokeWidth={2.5} /> Пауза
</>
) : (
<>
<Play size={14} strokeWidth={2.5} /> Старт
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={15} strokeWidth={2.5} /> Добавить
</Button>
</div>
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
<span className="text-gradient-brand">Дашборд</span>
</h1>
<p className="text-sm text-muted mt-2">
{stats.active} активных из {stats.total} упражнений ·{' '}
<span className="text-text font-mono-num font-semibold">
{stats.totalReps}
</span>{' '}
повторов за цикл
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="secondary" onClick={togglePause}>
{!paused ? (
<>
<Pause size={16} /> Пауза
</>
) : (
<>
<Play size={16} /> Возобновить
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={16} /> Новое
</Button>
</div>
</div>
{/* HUD stat strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<HudStat
icon={<Timer size={18} />}
label="До следующего"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'СЕЙЧАС'
: formatCountdown(stats.nextMs)
}
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
/>
<HudStat
icon={<Activity size={18} />}
label="Активных"
value={`${stats.active}/${stats.total}`}
/>
<HudStat
icon={<Flame size={18} />}
label="Avg интервал"
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'}
/>
<HudStat
icon={<Gamepad2 size={18} />}
label="Трекинг матчей"
value={gamesEnabled ? 'LIVE' : 'OFF'}
accent={gamesEnabled}
tone={gamesEnabled ? 'victory' : 'muted'}
{/* Hero stat panel — Apple Fitness style */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
<HeroStat
tone="accent"
label="Активных"
value={`${stats.active}`}
subvalue={`из ${stats.total}`}
icon={<Activity size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone="info"
label="До следующего"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'Сейчас'
: formatCountdown(stats.nextMs)
}
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
icon={<Flame size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={gamesEnabled ? 'success' : 'muted'}
label="Трекинг матчей"
value={gamesEnabled ? 'On' : 'Off'}
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
icon={
<span
className={[
'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30'
].join(' ')}
/>
}
/>
</div>
{/* Paused banner */}
{paused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
>
<div className="w-9 h-9 rounded-xl bg-warning/15 text-warning grid place-items-center">
<Pause size={16} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold">
Напоминания на паузе
</div>
<div className="text-[12px] text-text/55 mt-0.5">
Возобнови, чтобы продолжить отсчёт
</div>
</div>
<Button variant="filled" size="sm" onClick={togglePause}>
<Play size={13} strokeWidth={2.5} /> Старт
</Button>
</motion.div>
)}
{/* Cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
<ExerciseCard
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
))}
</AnimatePresence>
</div>
{exercises.length === 0 && (
<div className="mt-12 text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="font-display text-[20px] font-semibold">
Программа пуста
</div>
<p className="text-[14px] text-text/55 mt-1">
Добавь первое упражнение, чтобы начать
</p>
</div>
)}
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
</div>
{/* Paused banner */}
{paused && (
<div className="mb-6 rounded-2xl border border-xp/30 bg-xp/10 px-5 py-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-xp/20 text-xp grid place-items-center">
<Pause size={16} />
</div>
<div className="flex-1">
<div className="font-semibold text-sm">Тренировка на паузе</div>
<div className="text-xs text-muted mt-0.5">
Напоминания не сработают, пока не возобновишь
</div>
</div>
<Button variant="victory" size="sm" onClick={togglePause}>
<Play size={14} /> GO
</Button>
</div>
)}
{/* Challenges shortcut */}
{challenges.length > 0 && (
<div className="mb-6 rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm px-5 py-4 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-accent-2/15 text-accent-2 grid place-items-center">
<Trophy size={20} />
</div>
<div className="flex-1">
<div className="text-[10px] text-muted uppercase tracking-[0.18em] font-semibold">
Активные челленджи
</div>
<div className="text-base font-display font-semibold mt-0.5">
{challenges.length} правил привязано к матчам
</div>
</div>
<div className="hidden sm:block text-xs text-muted">
См. вкладку <span className="text-accent font-semibold">Челленджи</span>
</div>
</div>
)}
{/* Exercise grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
<ExerciseCard
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
onEdit={() => openEdit(ex)}
onDelete={() => handleDelete(ex.id)}
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
))}
</AnimatePresence>
</div>
{exercises.length === 0 && (
<div className="mt-12 text-center">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-4">
<Plus size={28} />
</div>
<div className="font-display text-xl font-semibold uppercase tracking-wider mb-1">
Старт пуст
</div>
<p className="text-sm text-muted">
Добавь первое упражнение и поехали
</p>
</div>
)}
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
</div>
)
}
function HudStat({
icon,
function HeroStat({
tone,
label,
value,
accent,
tone = 'accent'
subvalue,
icon
}: {
icon: React.ReactNode
tone: 'accent' | 'info' | 'success' | 'muted'
label: string
value: string
accent?: boolean
tone?: 'accent' | 'victory' | 'muted'
subvalue?: string
icon?: React.ReactNode
}): JSX.Element {
const toneClasses =
tone === 'victory'
? 'text-victory bg-victory/15'
: tone === 'muted'
? 'text-muted bg-surface-elevated'
: 'text-accent bg-accent/15'
const toneBg =
tone === 'accent'
? 'bg-accent'
: tone === 'info'
? 'bg-info'
: tone === 'success'
? 'bg-success'
: 'bg-text/40'
return (
<div
className={[
'relative rounded-2xl border bg-surface/60 backdrop-blur-sm px-4 py-3 overflow-hidden',
accent ? 'border-accent/40 shadow-glow' : 'border-border/70'
].join(' ')}
>
{accent && (
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-accent/20 blur-2xl pointer-events-none" />
)}
<div className="relative flex items-center gap-3">
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center gap-2 mb-3">
<div
className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses
'w-6 h-6 rounded-md grid place-items-center text-white',
toneBg
].join(' ')}
>
{icon}
</div>
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
{label}
</div>
<div className="font-display font-bold text-xl tracking-wide truncate">
{value}
</div>
</div>
<div className="text-[12px] text-text/55 font-medium">{label}</div>
</div>
<div className="font-display text-[30px] font-semibold tracking-tight leading-none">
{value}
</div>
{subvalue && (
<div className="text-[12px] text-text/45 mt-1.5">{subvalue}</div>
)}
</div>
)
}

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Plus, Pencil, Trash2, ListChecks } from 'lucide-react'
import { motion } from 'framer-motion'
import { Plus, ChevronRight } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format'
import type { Exercise } from '@shared/types'
@@ -14,138 +14,137 @@ export default function Exercises(): JSX.Element {
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const enabledCount = exercises.filter((e) => e.enabled).length
const totalReps = exercises
.filter((e) => e.enabled)
.reduce((s, e) => s + e.reps, 0)
const enabled = exercises.filter((e) => e.enabled)
const disabled = exercises.filter((e) => !e.enabled)
return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Программа
<div className="h-full overflow-y-auto">
<div className="max-w-3xl 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-8">
<div>
<div className="text-[13px] text-text/45 font-medium">
Программа
</div>
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
Упражнения
</h1>
</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 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(' ')}
<Button
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<div
className={[
'w-11 h-11 rounded-xl grid place-items-center shrink-0 transition-colors',
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>
<Plus size={15} strokeWidth={2.5} /> Добавить
</Button>
</div>
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={async (draft) => {
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)
setEditorOpen(false)
}}
/>
{enabled.length > 0 && (
<>
<SectionHeader title={`Активные · ${enabled.length}`} />
<Card className="mb-6">
{enabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
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/55 text-[14px]">
Программа пуста добавь первое упражнение
</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>
)
}
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-[15px] font-medium truncate leading-tight">
{exercise.name}
</div>
<div className="text-[13px] text-text/55 mt-0.5">
{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,
Hourglass,
Gamepad2,
Radio,
AlertTriangle
} from 'lucide-react'
import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types'
export default function GamesPage(): JSX.Element {
@@ -27,7 +27,6 @@ export default function GamesPage(): JSX.Element {
async function refresh(): Promise<void> {
setGames(await window.api.listGames())
}
async function install(id: GameId): Promise<void> {
setBusy(id)
try {
@@ -36,7 +35,6 @@ export default function GamesPage(): JSX.Element {
setBusy(null)
}
}
async function uninstall(id: GameId): Promise<void> {
setBusy(id)
try {
@@ -45,7 +43,6 @@ export default function GamesPage(): JSX.Element {
setBusy(null)
}
}
async function toggle(id: GameId, enabled: boolean): Promise<void> {
setBusy(id)
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 (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-3xl">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Трекинг матчей
</div>
<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 className="h-full overflow-y-auto">
<div className="max-w-3xl 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-8">
<div>
<div className="text-[13px] text-text/45 font-medium">
Трекинг матчей
</div>
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
Игры
</h1>
<p className="text-[14px] text-text/55 mt-2">
Подключи игру челленджи сработают сразу после матча
{liveCount > 0 && (
<>
{' · '}
<span className="text-success font-mono-num font-semibold">
{liveCount} live
</span>
</>
)}
</p>
</div>
)}
</div>
<Button variant="tinted" onClick={refresh}>
<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>
)
}
function GameRow({
function GameCard({
game,
busy,
onInstall,
@@ -139,77 +137,54 @@ function GameRow({
game.enabled
return (
<div
className={[
'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="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 min-w-0 flex-1">
{/* Game icon plaque */}
<div className="relative shrink-0">
<div
className={[
'absolute inset-0 rounded-2xl blur-md opacity-60',
isLive
? 'bg-gradient-victory'
: game.integrationActive
? 'bg-gradient-brand'
: ''
].join(' ')}
/>
<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
className={[
'w-12 h-12 rounded-2xl grid place-items-center shrink-0 text-white',
isLive
? 'bg-success'
: game.integrationActive
? 'bg-accent'
: 'bg-text/30'
].join(' ')}
>
<Gamepad2 size={22} strokeWidth={2.3} />
</div>
<div className="flex-1 min-w-0">
<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-[17px] font-semibold tracking-tight">
{game.name}
</h3>
<StatusBadge game={game} isLive={isLive} />
</div>
{game.installPath && (
<div className="text-[11px] text-muted mt-1.5 truncate font-mono opacity-70">
<div className="text-[12px] text-text/45 mt-1 truncate font-mono-num">
{game.installPath}
</div>
)}
</div>
</div>
{game.installed && game.integrationActive && (
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
<Switch
checked={game.enabled}
onChange={onToggle}
disabled={busy}
/>
)}
</div>
{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">
<Hourglass size={16} className="text-xp shrink-0 mt-0.5" />
<div>
<div className="mt-4 rounded-2xl bg-warning/12 p-3.5 text-[13px] leading-relaxed flex items-start gap-2.5">
<Hourglass
size={15}
className="text-warning shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/80">
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-[12px]">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam.
@@ -218,29 +193,38 @@ function GameRow({
)}
{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">
<AlertTriangle size={16} className="text-defeat shrink-0 mt-0.5" />
<div>
<div className="mt-4 rounded-2xl bg-destructive/10 p-3.5 text-[13px] leading-relaxed flex items-start gap-2.5">
<AlertTriangle
size={15}
className="text-destructive shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/80">
В Steam нет залогиненного аккаунта (нет папки{' '}
<code className="font-mono text-xs">userdata</code>). Запусти Steam
один раз потом снова нажми «Установить интеграцию».
<code className="font-mono-num text-[12px]">userdata</code>).
Запусти Steam один раз и нажми «Установить интеграцию».
</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 && (
<Button onClick={onInstall} disabled={busy}>
<Download size={16} /> Установить интеграцию
<Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> Подключить
</Button>
)}
{game.integrationActive && (
<Button variant="secondary" onClick={onUninstall} disabled={busy}>
<Trash2 size={16} /> Удалить интеграцию
<Button
variant="tinted"
onClick={onUninstall}
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> Отключить
</Button>
)}
{!game.installed && (
<div className="text-xs text-muted">
<div className="text-[13px] text-text/55">
Установи игру в Steam и нажми «Обновить»
</div>
)}
@@ -258,10 +242,10 @@ function StatusBadge({
}): JSX.Element {
if (isLive) {
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="absolute inline-flex h-full w-full rounded-full bg-victory opacity-70 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-victory" />
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Live
</span>
@@ -269,28 +253,28 @@ function StatusBadge({
}
if (game.integrationActive && game.launchOptionStatus === 'applied') {
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">
<CheckCircle2 size={11} /> Ready
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
<CheckCircle2 size={11} strokeWidth={2.5} /> Готово
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'queued') {
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">
<Radio size={11} /> Queued
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
В очереди
</span>
)
}
if (game.installed) {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-text font-display font-bold uppercase tracking-widest">
Installed
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
Установлена
</span>
)
}
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-display font-bold uppercase tracking-widest">
Not found
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
Не найдена
</span>
)
}
@@ -300,10 +284,10 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null
return (
<div className="mt-8 pt-6 border-t border-border/40">
<div className="mt-10">
<button
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 · симулировать конец матча
</button>
@@ -323,7 +307,7 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
<button
key={p.label}
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-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}
</button>

View File

@@ -1,164 +1,144 @@
import { Bell, Monitor, Palette } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import 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 {
const settings = useAppStore((s) => s.state?.settings)
if (!settings)
return (
<div className="p-8 text-muted font-display uppercase tracking-wider">
Загрузка
</div>
)
return <div className="p-8 text-text/45">Загрузка</div>
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
}
return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-2xl">
<div className="mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Конфигурация
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-8">
<div className="text-[13px] text-text/45 font-medium">
Конфигурация
</div>
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
Настройки
</h1>
</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">
Тонкая настройка поведения приложения
</p>
{/* Reminders */}
<SectionHeader title="Напоминания" />
<Card className="mb-6">
<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 минут' }
]}
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>
<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>
)
}
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({
label,
hint,
checked,
onChange,
disabled
disabled,
last = false
}: {
label: string
hint?: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
last?: boolean
}): JSX.Element {
return (
<div
className={[
'flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors',
disabled ? 'opacity-50' : 'hover:bg-accent/[0.03]'
].join(' ')}
>
<div className="flex-1">
<div className="font-display font-semibold text-sm tracking-wide">
{label}
</div>
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium leading-tight">{label}</div>
{hint && (
<div className="text-[12px] text-text/55 mt-0.5">{hint}</div>
)}
</div>
<Switch checked={checked} onChange={onChange} disabled={disabled} />
</div>
</Row>
)
}
@@ -167,26 +147,28 @@ function SelectRow({
hint,
value,
onChange,
options
options,
last = false
}: {
label: string
hint?: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
last?: boolean
}): JSX.Element {
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]">
<div className="flex-1">
<div className="font-display font-semibold text-sm tracking-wide">
{label}
</div>
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
<Row last={last}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium leading-tight">{label}</div>
{hint && (
<div className="text-[12px] text-text/55 mt-0.5">{hint}</div>
)}
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent 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) => (
<option key={o.value} value={o.value}>
@@ -194,6 +176,6 @@ function SelectRow({
</option>
))}
</select>
</div>
</Row>
)
}

View File

@@ -2,39 +2,61 @@
@tailwind components;
@tailwind utilities;
/* ===== Design tokens — Apple Human Interface Guidelines ===== */
:root {
/* Sport palette — Strava-inspired energy orange + intense rose pair */
--accent: 249 115 22; /* orange-500 — primary energy / "GO" */
--accent-soft: 249 115 22;
--accent-2: 244 63 94; /* rose-500 — gradient pair, intensity */
--victory: 132 204 22; /* lime-500 — done / personal best */
--defeat: 220 38 38; /* red-600 — danger */
--xp: 245 158 11; /* amber-500 — streak / XP */
/* Brand & semantic colors (iOS system palette) */
--accent: 255 107 53; /* Apple Fitness Move orange */
--accent-2: 255 45 85; /* systemPink */
--success: 52 199 89; /* systemGreen */
--warning: 255 159 10; /* systemOrange-dark */
--destructive: 255 59 48; /* systemRed */
--info: 0 122 255; /* systemBlue */
color-scheme: light dark;
}
/* Light theme — clean athletic paper feel */
/* Light — iOS groupedBackground feel */
:root {
--bg: 250 250 251;
--bg-deep: 240 240 244;
--surface: 255 255 255;
--surface-elevated: 248 248 250;
--border: 226 228 234;
--text: 17 18 24;
--muted: 102 105 120;
--bg: 242 242 247; /* systemGroupedBackground */
--surface: 255 255 255; /* secondarySystemGroupedBackground (cards) */
--surface-2: 242 242 247; /* tertiarySystemGroupedBackground */
--text: 0 0 0;
--text-secondary: 60 60 67; /* used with opacity 0.6 */
--text-tertiary: 60 60 67; /* used with opacity 0.3 */
--hairline: 60 60 67; /* used with opacity 0.18 */
--vibrancy: 255 255 255; /* sidebar translucent base */
/* Legacy tokens (mapped to keep some old utility classes working) */
--accent-soft: 255 107 53;
--bg-deep: 230 230 235;
--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 — iOS true black for OLED, elevation via grey steps */
.dark {
--bg: 13 14 18;
--bg-deep: 7 8 11;
--surface: 22 24 30;
--surface-elevated: 30 32 40;
--border: 50 53 64;
--text: 236 238 244;
--muted: 148 152 165;
--bg: 0 0 0; /* systemBackground */
--surface: 28 28 30; /* secondarySystemBackground */
--surface-2: 44 44 46; /* tertiarySystemBackground */
--text: 255 255 255;
--text-secondary: 235 235 245;
--text-tertiary: 235 235 245;
--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,
body,
#root {
@@ -45,48 +67,44 @@ body,
}
body {
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
font-family:
'Geist',
-apple-system,
'SF Pro Text',
'Segoe UI Variable Text',
'Segoe UI',
system-ui,
sans-serif;
font-feature-settings: 'cv11', 'ss01', 'ss03'; /* Geist stylistic alts */
background-color: rgb(var(--bg));
color: rgb(var(--text));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: rgb(var(--bg));
background-image:
radial-gradient(
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;
letter-spacing: -0.01em;
}
.dark body {
background-image:
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-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif;
letter-spacing: 0.02em;
font-family:
'Geist',
-apple-system,
'SF Pro Display',
'Segoe UI Variable Display',
system-ui,
sans-serif;
letter-spacing: -0.02em;
}
.font-serif {
font-family: 'Instrument Serif', 'Iowan Old Style', 'Apple Garamond', Georgia,
serif;
letter-spacing: -0.01em;
}
.font-mono-num {
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
font-family: 'Geist Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo,
monospace;
font-variant-numeric: tabular-nums;
font-feature-settings: 'ss02';
}
/* Custom titlebar drag region */
@@ -99,21 +117,49 @@ body {
app-region: no-drag;
}
/* Scrollbar */
/* Thin iOS-style hairline (effectively 0.5px when device DPR allows) */
.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);
}
/* Vibrancy panel (macOS Big Sur+ sidebar feel) */
.vibrancy {
background-color: rgb(var(--vibrancy) / 0.72);
backdrop-filter: saturate(180%) blur(30px);
-webkit-backdrop-filter: saturate(180%) blur(30px);
}
/* iOS-style soft card shadow */
.shadow-card {
box-shadow:
0 0.5px 0 rgb(0 0 0 / 0.03),
0 1px 2px rgb(0 0 0 / 0.04),
0 4px 12px rgb(0 0 0 / 0.04);
}
.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 — thin, iOS-style */
::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
background: rgb(var(--text-tertiary) / 0.3);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--accent) / 0.4);
background-clip: padding-box;
border: 2px solid transparent;
background: rgb(var(--text-secondary) / 0.45);
}
::-webkit-scrollbar-track {
background: transparent;
@@ -121,138 +167,38 @@ body {
/* Selection */
::selection {
background: rgb(var(--accent) / 0.4);
color: rgb(var(--text));
background: rgb(var(--accent) / 0.25);
}
/* Reminder-window root: neon HUD frame */
/* iOS focus ring */
*:focus-visible {
outline: 2px solid rgb(var(--accent) / 0.55);
outline-offset: 2px;
}
/* Reminder window root — iOS sheet */
.reminder-shell {
position: relative;
border: 1px solid rgb(var(--accent) / 0.5);
border-radius: 20px;
background:
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%);
border: 0.5px solid rgb(var(--hairline) / 0.25);
border-radius: 22px;
background: rgb(var(--surface));
box-shadow:
0 0 0 1px rgb(var(--accent) / 0.15),
0 20px 80px -20px rgb(var(--accent) / 0.45),
0 24px 60px -20px rgb(0 0 0 / 0.6);
0 1px 2px rgb(0 0 0 / 0.06),
0 20px 50px -16px rgb(0 0 0 / 0.4);
overflow: hidden;
height: 100%;
}
/* Soft scanline texture for HUD surfaces */
.hud-scanlines {
background-image: repeating-linear-gradient(
180deg,
rgb(var(--text) / 0.03) 0px,
rgb(var(--text) / 0.03) 1px,
transparent 1px,
transparent 3px
);
/* Text helpers (semantic aliases) */
.text-secondary {
color: rgb(var(--text-secondary) / 0.6);
}
/* Gradient text and gradient brand */
.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;
.text-tertiary {
color: rgb(var(--text-tertiary) / 0.3);
}
.bg-gradient-brand {
background-image: linear-gradient(
135deg,
rgb(var(--accent)) 0%,
rgb(var(--accent-2)) 100%
);
.dark .text-secondary {
color: rgb(var(--text-secondary) / 0.6);
}
.bg-gradient-victory {
background-image: linear-gradient(
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;
.dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3);
}

View File

@@ -5,60 +5,77 @@ export default {
theme: {
extend: {
colors: {
// iOS semantic palette
accent: 'rgb(var(--accent) / <alpha-value>)',
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
victory: 'rgb(var(--victory) / <alpha-value>)',
defeat: 'rgb(var(--defeat) / <alpha-value>)',
xp: 'rgb(var(--xp) / <alpha-value>)',
success: 'rgb(var(--success) / <alpha-value>)',
warning: 'rgb(var(--warning) / <alpha-value>)',
destructive: 'rgb(var(--destructive) / <alpha-value>)',
info: 'rgb(var(--info) / <alpha-value>)',
// Surfaces
bg: 'rgb(var(--bg) / <alpha-value>)',
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
surface: 'rgb(var(--surface) / <alpha-value>)',
'surface-2': 'rgb(var(--surface-2) / <alpha-value>)',
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
// Text & lines
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: {
sans: ['Inter', 'Segoe UI Variable', 'Segoe UI', 'system-ui', 'sans-serif'],
display: ['Rajdhani', 'Inter', 'Segoe UI Variable', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'Menlo', 'monospace']
sans: [
'Geist',
'-apple-system',
'SF Pro Text',
'Segoe UI Variable Text',
'Segoe UI',
'system-ui',
'sans-serif'
],
display: [
'Geist',
'-apple-system',
'SF Pro Display',
'Segoe UI Variable Display',
'system-ui',
'sans-serif'
],
serif: [
'Instrument Serif',
'Iowan Old Style',
'Apple Garamond',
'Georgia',
'serif'
],
mono: [
'Geist Mono',
'ui-monospace',
'SF Mono',
'Cascadia Code',
'Menlo',
'monospace'
]
},
borderRadius: {
// iOS-specific radii
xl: '14px',
'2xl': '18px',
'3xl': '22px'
},
boxShadow: {
soft: '0 8px 30px -12px rgb(0 0 0 / 0.35)',
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.55)',
'glow-lg':
'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%' }
}
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)',
sheet:
'0 1px 2px rgb(0 0 0 / 0.06), 0 20px 50px -16px rgb(0 0 0 / 0.4)'
}
}
},