Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b488164e0 | ||
|
|
aa60acb164 | ||
|
|
660b6d57d8 | ||
|
|
c5a29214d2 | ||
|
|
6ffa100645 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.claude/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "laude",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"description": "Exercise reminder — Windows desktop app",
|
||||
"main": "out/main/index.js",
|
||||
"author": "AnRil",
|
||||
|
||||
161
scripts/upload-release-assets.ps1
Normal file
161
scripts/upload-release-assets.ps1
Normal 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"
|
||||
@@ -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=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-[13px] uppercase tracking-[0.18em] text-accent font-bold">
|
||||
Время тренировки
|
||||
</div>
|
||||
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide">
|
||||
<h1 className="font-serif text-[28px] leading-tight tracking-tight mt-2 mb-3 font-medium">
|
||||
{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-[56px] 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/65 font-semibold">раз</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
|
||||
<Clock size={10} />
|
||||
|
||||
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
||||
<Clock size={12} strokeWidth={2.4} />
|
||||
Следующее через {formatInterval(exercise.intervalMinutes)}
|
||||
</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-[16px] font-bold 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={17} 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-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Clock size={15} strokeWidth={2.5} /> {snoozeMinutes} мин
|
||||
</button>
|
||||
<button
|
||||
onClick={skip}
|
||||
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
Пропустить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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-[12px] text-text/65 font-semibold inline-flex items-center gap-1.5 px-2">
|
||||
<Gamepad2 size={12} 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} />
|
||||
<Trophy size={26} strokeWidth={2} />
|
||||
) : lost ? (
|
||||
<Skull size={30} />
|
||||
<Frown size={26} strokeWidth={2} />
|
||||
) : (
|
||||
<Gamepad2 size={30} />
|
||||
<Gamepad2 size={26} strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
|
||||
{won
|
||||
? 'Победа · тренировка заработана'
|
||||
: lost
|
||||
? 'Проигрыш · но тело не сдаётся'
|
||||
: 'Матч завершён'}
|
||||
<h1 className="font-serif text-[24px] tracking-tight font-medium">
|
||||
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted mt-1.5">
|
||||
<span className="font-mono-num font-semibold text-text">
|
||||
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
|
||||
<span className="font-mono-num font-bold text-text">
|
||||
{Math.floor(summary.durationMs / 60_000)}
|
||||
</span>{' '}
|
||||
мин · {summary.results.length}{' '}
|
||||
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||
мин · {summary.results.length} челлендж
|
||||
{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||
{allDone ? (
|
||||
<span className="text-victory font-semibold uppercase tracking-wider">
|
||||
готово
|
||||
</span>
|
||||
<span className="text-success font-bold">всё готово</span>
|
||||
) : (
|
||||
<span className="text-accent font-bold font-mono-num">
|
||||
<span className="text-accent font-mono-num font-bold">
|
||||
{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-[13px] text-text/65 font-medium">
|
||||
Всего ·{' '}
|
||||
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
|
||||
<span className="text-text font-mono-num font-bold text-[16px]">
|
||||
{totalReps}
|
||||
</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-[15px] font-semibold truncate',
|
||||
done ? 'line-through opacity-55' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{result.exerciseName}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">
|
||||
<div className="text-[13px] text-text/65 mt-0.5 font-medium">
|
||||
<span className="font-mono-num font-bold text-text">
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -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,32 +43,14 @@ 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="flex items-start gap-4">
|
||||
{/* Icon + progress ring */}
|
||||
<div className="relative w-14 h-14 shrink-0">
|
||||
<svg
|
||||
className="absolute inset-0 -rotate-90"
|
||||
@@ -69,56 +58,104 @@ export function ExerciseCard({
|
||||
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>
|
||||
<circle
|
||||
className="cooldown-track"
|
||||
cx="28"
|
||||
cy="28"
|
||||
r={R}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
strokeWidth="2.5"
|
||||
className="stroke-hairline/15 dark:stroke-hairline/30"
|
||||
/>
|
||||
{exercise.enabled && (
|
||||
<circle
|
||||
className="cooldown-fill"
|
||||
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' }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-[7px] rounded-full grid place-items-center transition-colors',
|
||||
'absolute inset-[8px] rounded-full grid place-items-center',
|
||||
exercise.enabled
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-elevated text-muted'
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'bg-surface-2 text-text/40'
|
||||
].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">
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-display text-[18px] font-bold 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 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 className="text-[14px] text-text/65 mt-1 font-medium">
|
||||
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
||||
</div>
|
||||
|
||||
{/* Countdown + switch */}
|
||||
<div className="flex items-end justify-between mt-3.5">
|
||||
<div>
|
||||
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
|
||||
{isDue ? 'Сейчас' : 'Через'}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
|
||||
isDue ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={exercise.enabled}
|
||||
@@ -126,55 +163,20 @@ export function ExerciseCard({
|
||||
aria-label="Включить/выключить"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||
Через
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'text-sm font-mono-num font-bold',
|
||||
isDue ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
|
||||
</span>
|
||||
</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>
|
||||
</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-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Check size={14} /> Сделал
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||
Preview
|
||||
</div>
|
||||
<div className="font-display font-bold text-lg uppercase tracking-wide truncate">
|
||||
<div className="font-display text-[18px] font-semibold tracking-tight 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}
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Gamepad2,
|
||||
Target,
|
||||
Settings as SettingsIcon,
|
||||
Sun,
|
||||
Dumbbell,
|
||||
Joystick,
|
||||
Flame,
|
||||
Settings2,
|
||||
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 Sun
|
||||
end?: boolean
|
||||
tint?: string
|
||||
}
|
||||
|
||||
// Tinted icon plaques á la iOS Settings rows.
|
||||
const items: Item[] = [
|
||||
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
|
||||
{
|
||||
to: '/exercises',
|
||||
label: 'Упражнения',
|
||||
icon: Dumbbell,
|
||||
tint: 'bg-info'
|
||||
},
|
||||
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
|
||||
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
|
||||
{
|
||||
to: '/settings',
|
||||
label: 'Настройки',
|
||||
icon: Settings2,
|
||||
tint: 'bg-text/70'
|
||||
}
|
||||
]
|
||||
|
||||
type Props = {
|
||||
@@ -23,12 +41,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 +60,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 +92,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 className="px-5 pt-7 pb-6">
|
||||
<div className="font-serif text-[34px] leading-none tracking-tight font-medium">
|
||||
Laude
|
||||
</div>
|
||||
<div className="text-[12px] text-text/45 mt-2 tracking-tight">
|
||||
Двигайся осознанно
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'min-w-0',
|
||||
compact ? 'hidden lg:block' : 'block'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-display font-bold text-lg leading-none uppercase tracking-wider">
|
||||
<span className="text-gradient-brand">Laude</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted uppercase tracking-[0.18em] mt-1">
|
||||
Move every day
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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-1">
|
||||
{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-8 h-8 rounded-[9px] 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={17} strokeWidth={2.2} />
|
||||
</div>
|
||||
<span
|
||||
className={[
|
||||
compact ? 'hidden lg:inline' : 'inline',
|
||||
isActive ? 'font-semibold' : ''
|
||||
'text-[15px] truncate',
|
||||
isActive
|
||||
? 'text-text font-semibold'
|
||||
: 'text-text/85 font-medium'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
@@ -158,26 +148,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" />
|
||||
{/* 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 className="text-[10px] uppercase tracking-[0.18em] text-muted">
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted/80 leading-snug">
|
||||
Трекинг активности включён
|
||||
</div>
|
||||
Активность отслеживается
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
{/* Centre title */}
|
||||
<div className="text-[12px] font-medium text-text/55 truncate px-2">
|
||||
{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
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-10 h-10 rounded-xl bg-accent/12 text-accent grid place-items-center">
|
||||
<Download size={17} 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-semibold leading-tight">
|
||||
Загружаем обновление
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-0.5 font-mono-num">
|
||||
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
|
||||
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
|
||||
{(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-bold text-[18px] 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-4">
|
||||
<div
|
||||
className={[
|
||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||
toneClasses
|
||||
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-semibold leading-tight">{title}</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs text-muted mt-0.5 truncate">{subtitle}</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 truncate font-medium">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
@@ -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(' ')}
|
||||
|
||||
89
src/renderer/src/components/ui/Card.tsx
Normal file
89
src/renderer/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* iOS grouped-list card. Wraps rows; first/last row hairline rules handled
|
||||
* by `Row` itself (only top/middle hairline shown).
|
||||
*/
|
||||
export function Card({
|
||||
children,
|
||||
className = ''
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'bg-surface rounded-2xl overflow-hidden',
|
||||
'shadow-[0_0.5px_0_rgb(0_0_0_/_0.04),_0_1px_2px_rgb(0_0_0_/_0.04)]',
|
||||
'dark:shadow-none dark:ring-0.5 dark:ring-hairline/30',
|
||||
className
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Section heading above a Card group — iOS Settings style: tiny uppercase
|
||||
* label, generous spacing.
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
hint,
|
||||
action
|
||||
}: {
|
||||
title: string
|
||||
hint?: string
|
||||
action?: ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-end justify-between px-4 mb-2.5">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold uppercase tracking-[0.06em] text-text/60">
|
||||
{title}
|
||||
</div>
|
||||
{hint && (
|
||||
<div className="text-[13px] text-text/55 mt-0.5 font-medium">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single row inside Card — left icon (optional), title + subtitle, right
|
||||
* accessory (switch / chevron / button). Adds bottom hairline unless `last`.
|
||||
*/
|
||||
export function Row({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
last = false
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
last?: boolean
|
||||
}): JSX.Element {
|
||||
const interactive = !!onClick
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={[
|
||||
'flex items-center gap-3 px-4 py-3 relative',
|
||||
interactive
|
||||
? 'cursor-pointer active:bg-surface-2 dark:active:bg-white/5 transition-colors'
|
||||
: '',
|
||||
!last ? 'hairline-b' : '',
|
||||
className
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
{/* 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}
|
||||
</h3>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,135 +41,98 @@ 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 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-[14px] text-text/65 font-semibold">
|
||||
Правила за матч
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Челленджи</span>
|
||||
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||
Челленджи
|
||||
</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 className="text-[15px] text-text/65 mt-2 font-medium">
|
||||
Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start sm:self-auto flex-shrink-0"
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={16} /> Новый
|
||||
<Plus size={15} strokeWidth={2.5} /> Новый
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
||||
{noGamesActive && (
|
||||
<div className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
|
||||
<AlertTriangle size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
|
||||
Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
|
||||
<span className="font-semibold text-text">«Игры»</span>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{challenges.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title={`Все · ${challenges.length}`} />
|
||||
<Card>
|
||||
{challenges.map((c, i) => (
|
||||
<motion.div
|
||||
<Row
|
||||
key={c.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
className={[
|
||||
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
||||
c.enabled
|
||||
? 'hover:bg-accent/[0.04]'
|
||||
: 'opacity-70 hover:opacity-100',
|
||||
i < challenges.length - 1 ? 'border-b border-border/40' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
||||
c.enabled
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={c.icon} size={20} />
|
||||
</div>
|
||||
<div 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
|
||||
last={i === challenges.length - 1}
|
||||
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="Удалить"
|
||||
<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(' ')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
<Icon name={c.icon} size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="text-[14px] text-text/65 mt-1 inline-flex items-center gap-1.5 font-medium">
|
||||
<Gamepad2 size={12} strokeWidth={2.4} />
|
||||
{GAME_NAMES[c.gameId]} ·{' '}
|
||||
<span className="font-mono-num font-semibold text-text">
|
||||
{STAT_LABELS[c.stat]} × {c.multiplier}
|
||||
</span>{' '}
|
||||
→ {c.exerciseName}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={c.enabled}
|
||||
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-text/30" />
|
||||
</Row>
|
||||
))}
|
||||
{challenges.length === 0 && (
|
||||
<div className="px-5 py-16 text-center">
|
||||
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
|
||||
<Target size={26} />
|
||||
</div>
|
||||
<div className="font-display text-lg font-semibold uppercase tracking-wider">
|
||||
Челленджей нет
|
||||
</div>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Привяжи первое упражнение к статистике матча
|
||||
</p>
|
||||
</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>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
|
||||
Челленджей пока нет. Привяжи упражнение к статистике матча.
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ChallengeEditor
|
||||
@@ -185,6 +146,7 @@ export default function ChallengesPage(): JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</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 событий
|
||||
{/* 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="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">
|
||||
<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-muted ml-1">
|
||||
{draft.exerciseName.toLowerCase() || 'упражнений'}
|
||||
<span className="text-text/55">
|
||||
{draft.exerciseName.toLowerCase() || 'повторов'}
|
||||
</span>
|
||||
</div>
|
||||
</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}
|
||||
|
||||
@@ -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,135 +48,116 @@ 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="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-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||
Тренировка дня
|
||||
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
||||
{today}
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Дашборд</span>
|
||||
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||
Сегодня
|
||||
</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}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="tinted" onClick={togglePause}>
|
||||
{!paused ? (
|
||||
<>
|
||||
<Pause size={16} /> Пауза
|
||||
<Pause size={14} strokeWidth={2.5} /> Пауза
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} /> Возобновить
|
||||
<Play size={14} strokeWidth={2.5} /> Старт
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} /> Новое
|
||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||
</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} />}
|
||||
{/* 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)
|
||||
}
|
||||
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
|
||||
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<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} />}
|
||||
<HeroStat
|
||||
tone={gamesEnabled ? 'success' : 'muted'}
|
||||
label="Трекинг матчей"
|
||||
value={gamesEnabled ? 'LIVE' : 'OFF'}
|
||||
accent={gamesEnabled}
|
||||
tone={gamesEnabled ? 'victory' : 'muted'}
|
||||
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 && (
|
||||
<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} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
|
||||
<Pause size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">Тренировка на паузе</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
Напоминания не сработают, пока не возобновишь
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[16px] font-semibold leading-tight">
|
||||
Напоминания на паузе
|
||||
</div>
|
||||
<div className="text-[14px] text-text/70 mt-1">
|
||||
Возобнови, чтобы продолжить отсчёт
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="victory" size="sm" onClick={togglePause}>
|
||||
<Play size={14} /> GO
|
||||
<Button variant="filled" size="sm" onClick={togglePause}>
|
||||
<Play size={14} strokeWidth={2.5} /> Старт
|
||||
</Button>
|
||||
</div>
|
||||
</motion.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">
|
||||
{/* Cards grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
||||
<AnimatePresence>
|
||||
{exercises.map((ex) => (
|
||||
<ExerciseCard
|
||||
@@ -202,8 +165,8 @@ export default function Dashboard(): JSX.Element {
|
||||
exercise={ex}
|
||||
tick={ticks[ex.id]}
|
||||
onEdit={() => openEdit(ex)}
|
||||
onDelete={() => handleDelete(ex.id)}
|
||||
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
|
||||
onDelete={() => window.api.deleteExercise(ex.id)}
|
||||
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
|
||||
onMarkDone={() => window.api.markDone(ex.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -212,14 +175,14 @@ export default function Dashboard(): JSX.Element {
|
||||
|
||||
{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 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-xl font-semibold uppercase tracking-wider mb-1">
|
||||
Старт пуст
|
||||
<div className="font-display text-[20px] font-semibold">
|
||||
Программа пуста
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Добавь первое упражнение — и поехали
|
||||
<p className="text-[14px] text-text/55 mt-1">
|
||||
Добавь первое упражнение, чтобы начать
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -231,56 +194,53 @@ export default function Dashboard(): JSX.Element {
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</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="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={[
|
||||
'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={[
|
||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||
toneClasses
|
||||
'w-7 h-7 rounded-lg 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 className="text-[14px] text-text/75 font-semibold">{label}</div>
|
||||
</div>
|
||||
<div className="font-display font-bold text-xl tracking-wide truncate">
|
||||
<div className="font-display text-[28px] font-bold tracking-tight leading-none">
|
||||
{value}
|
||||
</div>
|
||||
{subvalue && (
|
||||
<div className="text-[13px] text-text/60 mt-2 font-medium">
|
||||
{subvalue}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,127 +14,76 @@ 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-[14px] text-text/65 font-semibold">
|
||||
Программа
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Упражнения</span>
|
||||
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||
Упражнения
|
||||
</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} /> Добавить
|
||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
||||
{exercises.map((ex, i) => (
|
||||
<motion.div
|
||||
{enabled.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title={`Активные · ${enabled.length}`} />
|
||||
<Card className="mb-6">
|
||||
{enabled.map((ex, i) => (
|
||||
<ExerciseRow
|
||||
key={ex.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.02 }}
|
||||
className={[
|
||||
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
||||
ex.enabled
|
||||
? 'hover:bg-accent/[0.04]'
|
||||
: 'opacity-70 hover:opacity-100',
|
||||
i < exercises.length - 1 ? 'border-b border-border/40' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
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={() => {
|
||||
exercise={ex}
|
||||
last={i === enabled.length - 1}
|
||||
onEdit={() => {
|
||||
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>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{disabled.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title={`Выключенные · ${disabled.length}`} />
|
||||
<Card>
|
||||
{disabled.map((ex, i) => (
|
||||
<ExerciseRow
|
||||
key={ex.id}
|
||||
exercise={ex}
|
||||
last={i === disabled.length - 1}
|
||||
onEdit={() => {
|
||||
setEditing(ex)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{exercises.length === 0 && (
|
||||
<Card>
|
||||
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
|
||||
Программа пуста — добавь первое упражнение
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ExerciseEditor
|
||||
open={editorOpen}
|
||||
@@ -147,5 +96,55 @@ export default function Exercises(): JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseRow({
|
||||
exercise,
|
||||
last,
|
||||
onEdit
|
||||
}: {
|
||||
exercise: Exercise
|
||||
last: boolean
|
||||
onEdit: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Row last={last}>
|
||||
{/* Tinted icon plaque, iOS Settings style */}
|
||||
<div
|
||||
className={[
|
||||
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
||||
exercise.enabled
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-text/15 text-text/45'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={exercise.icon} size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 min-w-0 text-left active:opacity-70 transition-opacity"
|
||||
>
|
||||
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||
{exercise.name}
|
||||
</div>
|
||||
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
||||
{exercise.reps} раз · {formatInterval(exercise.intervalMinutes)}
|
||||
</div>
|
||||
</button>
|
||||
<Switch
|
||||
checked={exercise.enabled}
|
||||
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
|
||||
aria-label="Включить/выключить"
|
||||
/>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-text/30 hover:text-text/60 transition-colors"
|
||||
aria-label="Редактировать"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,48 +52,48 @@ 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 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-[14px] text-text/65 font-semibold">
|
||||
Трекинг матчей
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Игры</span>
|
||||
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||
Игры
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
|
||||
Подключи игру — челленджи сработают сразу после матча
|
||||
{liveCount > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-victory font-mono-num font-bold">
|
||||
{liveCount} LIVE
|
||||
<span className="text-success 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 variant="tinted" onClick={refresh}>
|
||||
<RefreshCw size={14} strokeWidth={2.5} /> Обновить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SectionHeader title="Поддерживаемые" />
|
||||
<div className="space-y-4">
|
||||
{games.map((g, i) => (
|
||||
<motion.div
|
||||
key={g.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
>
|
||||
<GameRow
|
||||
<GameCard
|
||||
game={g}
|
||||
busy={busy === g.id}
|
||||
onInstall={() => install(g.id)}
|
||||
@@ -106,20 +103,21 @@ export default function GamesPage(): JSX.Element {
|
||||
</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>
|
||||
<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',
|
||||
'w-12 h-12 rounded-2xl grid place-items-center shrink-0 text-white',
|
||||
isLive
|
||||
? 'bg-gradient-victory'
|
||||
? 'bg-success'
|
||||
: 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'
|
||||
? 'bg-accent'
|
||||
: 'bg-text/30'
|
||||
].join(' ')}
|
||||
>
|
||||
<Gamepad2 size={26} />
|
||||
<Gamepad2 size={22} strokeWidth={2.3} />
|
||||
</div>
|
||||
</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-[18px] font-bold 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-[13px] text-text/55 mt-1.5 truncate font-mono-num font-medium">
|
||||
{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-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
|
||||
<Hourglass
|
||||
size={17}
|
||||
className="text-warning shrink-0 mt-0.5"
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<div className="text-text/85">
|
||||
Steam запущен. Параметр{' '}
|
||||
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
|
||||
<code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
|
||||
{game.launchOption}
|
||||
</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-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
|
||||
<AlertTriangle
|
||||
size={17}
|
||||
className="text-destructive shrink-0 mt-0.5"
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<div className="text-text/85">
|
||||
В Steam нет залогиненного аккаунта (нет папки{' '}
|
||||
<code className="font-mono text-xs">userdata</code>). Запусти Steam
|
||||
один раз — потом снова нажми «Установить интеграцию».
|
||||
<code className="font-mono-num text-[13px] font-semibold">userdata</code>).
|
||||
Запусти Steam один раз и нажми «Установить интеграцию».
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div 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-[14px] text-text/65 font-medium">
|
||||
Установи игру в 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>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
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="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-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||
<div className="text-[14px] text-text/65 font-semibold">
|
||||
Конфигурация
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Настройки</span>
|
||||
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||
Настройки
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
Тонкая настройка поведения приложения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section title="Напоминания" icon={<Bell size={14} />}>
|
||||
{/* 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: 'И окно, и уведомление' }
|
||||
{ value: 'modal', label: 'Окно поверх всех' },
|
||||
{ value: 'toast', label: 'Системное уведомление' },
|
||||
{ value: 'both', label: 'Окно и уведомление' }
|
||||
]}
|
||||
/>
|
||||
<ToggleRow
|
||||
@@ -50,8 +50,8 @@ export default function SettingsPage(): JSX.Element {
|
||||
onChange={(v) => patch({ soundEnabled: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Интервал «Отложить»"
|
||||
hint="На сколько минут откладывать при нажатии «Отложить»"
|
||||
label="«Отложить» на"
|
||||
hint="Сколько минут добавлять при отложении"
|
||||
value={String(settings.snoozeMinutes)}
|
||||
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
||||
options={[
|
||||
@@ -61,19 +61,22 @@ export default function SettingsPage(): JSX.Element {
|
||||
{ value: '15', label: '15 минут' },
|
||||
{ value: '30', label: '30 минут' }
|
||||
]}
|
||||
last
|
||||
/>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<Section title="Окно и трей" icon={<Monitor size={14} />}>
|
||||
{/* Window */}
|
||||
<SectionHeader title="Окно и трей" />
|
||||
<Card className="mb-6">
|
||||
<ToggleRow
|
||||
label="Сворачивать в трей"
|
||||
hint="При закрытии окна приложение остаётся работать в системном трее"
|
||||
hint="При закрытии остаётся работать в фоне"
|
||||
checked={settings.minimizeToTray}
|
||||
onChange={(v) => patch({ minimizeToTray: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Запускать с Windows"
|
||||
hint="Открывать приложение автоматически при входе в систему"
|
||||
hint="Открывать при входе в систему"
|
||||
checked={settings.startWithWindows}
|
||||
onChange={(v) => patch({ startWithWindows: v })}
|
||||
/>
|
||||
@@ -83,13 +86,16 @@ export default function SettingsPage(): JSX.Element {
|
||||
checked={settings.startMinimized}
|
||||
onChange={(v) => patch({ startMinimized: v })}
|
||||
disabled={!settings.startWithWindows}
|
||||
last
|
||||
/>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<Section title="Внешний вид" icon={<Palette size={14} />}>
|
||||
{/* Appearance */}
|
||||
<SectionHeader title="Внешний вид" />
|
||||
<Card className="mb-6">
|
||||
<SelectRow
|
||||
label="Тема"
|
||||
hint="Тёмная подходит к спортивной эстетике приложения"
|
||||
hint="Светлая / тёмная / как в системе"
|
||||
value={settings.theme}
|
||||
onChange={(v) => patch({ theme: v as Theme })}
|
||||
options={[
|
||||
@@ -97,37 +103,14 @@ export default function SettingsPage(): JSX.Element {
|
||||
{ value: 'light', label: 'Светлая' },
|
||||
{ value: 'dark', label: 'Тёмная' }
|
||||
]}
|
||||
last
|
||||
/>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<SectionHeader title="Обновления" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,29 +119,28 @@ function ToggleRow({
|
||||
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}
|
||||
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">{label}</div>
|
||||
{hint && (
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{hint}
|
||||
</div>
|
||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
||||
)}
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,26 +149,30 @@ 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}
|
||||
<Row last={last}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">{label}</div>
|
||||
{hint && (
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{hint}
|
||||
</div>
|
||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent 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 +180,6 @@ function SelectRow({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,39 +2,60 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===== Design tokens — Apple HIG with Manrope/Fraunces ===== */
|
||||
|
||||
: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 — polished iOS groupedBackground with warm undertone */
|
||||
:root {
|
||||
--bg: 250 250 251;
|
||||
--bg-deep: 240 240 244;
|
||||
--bg: 245 245 249; /* slightly warmer than 242,242,247 */
|
||||
--surface: 255 255 255;
|
||||
--surface-elevated: 248 248 250;
|
||||
--border: 226 228 234;
|
||||
--text: 17 18 24;
|
||||
--muted: 102 105 120;
|
||||
--surface-2: 240 240 245; /* subtle separation for inputs/sections */
|
||||
--text: 17 17 19; /* not pure black — softer */
|
||||
--text-secondary: 60 60 67;
|
||||
--text-tertiary: 60 60 67;
|
||||
--hairline: 60 60 67;
|
||||
--vibrancy: 255 255 255;
|
||||
|
||||
--accent-soft: 255 107 53;
|
||||
--bg-deep: 232 232 238;
|
||||
--surface-elevated: 255 255 255;
|
||||
--border: 60 60 67;
|
||||
--muted: 60 60 67;
|
||||
--victory: 52 199 89;
|
||||
--defeat: 255 59 48;
|
||||
--xp: 255 159 10;
|
||||
}
|
||||
|
||||
/* Dark theme — warm graphite (not cool navy) */
|
||||
/* Dark — true black with grey elevation */
|
||||
.dark {
|
||||
--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;
|
||||
--surface: 28 28 30;
|
||||
--surface-2: 44 44 46;
|
||||
--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 +66,49 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
font-family:
|
||||
'Manrope',
|
||||
-apple-system,
|
||||
'SF Pro Text',
|
||||
'Segoe UI Variable Text',
|
||||
'Segoe UI',
|
||||
system-ui,
|
||||
sans-serif;
|
||||
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;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.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 */
|
||||
/* Display — same Manrope but slightly tighter for headings */
|
||||
.font-display {
|
||||
font-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
font-family:
|
||||
'Manrope',
|
||||
-apple-system,
|
||||
'SF Pro Display',
|
||||
'Segoe UI Variable Display',
|
||||
system-ui,
|
||||
sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Serif — Fraunces for hero titles with optical-size axis */
|
||||
.font-serif {
|
||||
font-family: 'Fraunces', 'Iowan Old Style', 'New York', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.font-mono-num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code',
|
||||
Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'ss02', 'ss19', 'zero';
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Custom titlebar drag region */
|
||||
@@ -99,160 +121,84 @@ body {
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
/* iOS 0.5px-style hairlines */
|
||||
.hairline-b {
|
||||
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.18);
|
||||
}
|
||||
.hairline-t {
|
||||
box-shadow: inset 0 0.5px 0 rgb(var(--hairline) / 0.18);
|
||||
}
|
||||
.dark .hairline-b,
|
||||
.dark .hairline-t {
|
||||
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.4);
|
||||
}
|
||||
|
||||
/* macOS vibrancy */
|
||||
.vibrancy {
|
||||
background-color: rgb(var(--vibrancy) / 0.72);
|
||||
backdrop-filter: saturate(180%) blur(30px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(30px);
|
||||
}
|
||||
|
||||
/* Soft iOS card shadow with subtle warmth */
|
||||
.shadow-card {
|
||||
box-shadow:
|
||||
0 0.5px 0 rgb(0 0 0 / 0.03),
|
||||
0 1px 2px rgb(15 23 42 / 0.04),
|
||||
0 6px 14px -4px rgb(15 23 42 / 0.05);
|
||||
}
|
||||
.dark .shadow-card {
|
||||
box-shadow:
|
||||
0 0.5px 0 rgb(255 255 255 / 0.04) inset,
|
||||
0 1px 2px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-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;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgb(var(--accent) / 0.4);
|
||||
color: rgb(var(--text));
|
||||
background: rgb(var(--accent) / 0.25);
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgb(var(--accent) / 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Reminder-window root: neon HUD frame */
|
||||
.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-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);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
startWithWindows: false,
|
||||
minimizeToTray: true,
|
||||
startMinimized: false,
|
||||
theme: 'system',
|
||||
theme: 'light',
|
||||
snoozeMinutes: 5
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
'Manrope',
|
||||
'-apple-system',
|
||||
'SF Pro Text',
|
||||
'Segoe UI Variable Text',
|
||||
'Segoe UI',
|
||||
'system-ui',
|
||||
'sans-serif'
|
||||
],
|
||||
display: [
|
||||
'Manrope',
|
||||
'-apple-system',
|
||||
'SF Pro Display',
|
||||
'Segoe UI Variable Display',
|
||||
'system-ui',
|
||||
'sans-serif'
|
||||
],
|
||||
serif: [
|
||||
'Fraunces',
|
||||
'Iowan Old Style',
|
||||
'New York',
|
||||
'Georgia',
|
||||
'serif'
|
||||
],
|
||||
mono: [
|
||||
'JetBrains 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)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user