Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b488164e0 | ||
|
|
aa60acb164 | ||
|
|
660b6d57d8 | ||
|
|
c5a29214d2 | ||
|
|
6ffa100645 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"description": "Exercise reminder — Windows desktop app",
|
"description": "Exercise reminder — Windows desktop app",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "AnRil",
|
"author": "AnRil",
|
||||||
|
|||||||
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>
|
<title>Exercise Reminder</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Rajdhani:wght@500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
|
import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { Titlebar } from './components/Titlebar'
|
import { Titlebar } from './components/Titlebar'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@@ -32,9 +33,9 @@ export default function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-hidden min-w-0">
|
<main className="flex-1 overflow-hidden min-w-0">
|
||||||
{hydrated ? (
|
{hydrated ? (
|
||||||
<RoutesWithCloseOnNav onClose={() => setMobileNavOpen(false)} />
|
<RoutedPages onNav={() => setMobileNavOpen(false)} />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-muted">Загрузка…</div>
|
<div className="p-8 text-text/45">Загрузка…</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,20 +44,31 @@ export default function App(): JSX.Element {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close mobile drawer whenever the route changes.
|
function RoutedPages({ onNav }: { onNav: () => void }): JSX.Element {
|
||||||
function RoutesWithCloseOnNav({ onClose }: { onClose: () => void }): JSX.Element {
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onClose()
|
onNav()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<motion.div
|
||||||
<Route path="/exercises" element={<Exercises />} />
|
key={location.pathname}
|
||||||
<Route path="/games" element={<GamesPage />} />
|
initial={{ opacity: 0, y: 6 }}
|
||||||
<Route path="/challenges" element={<ChallengesPage />} />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
exit={{ opacity: 0, y: -4 }}
|
||||||
</Routes>
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<Routes location={location}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/exercises" element={<Exercises />} />
|
||||||
|
<Route path="/games" element={<GamesPage />} />
|
||||||
|
<Route path="/challenges" element={<ChallengesPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import {
|
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
|
||||||
Check,
|
import type {
|
||||||
Clock,
|
Exercise,
|
||||||
X,
|
MatchSummary,
|
||||||
Trophy,
|
Settings,
|
||||||
Skull,
|
ChallengeResult
|
||||||
Gamepad2,
|
} from '@shared/types'
|
||||||
Flame,
|
|
||||||
Zap
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
|
|
||||||
import { Icon } from './lib/icon'
|
import { Icon } from './lib/icon'
|
||||||
import { formatInterval } from './lib/format'
|
import { formatInterval } from './lib/format'
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcuts on reminder window
|
// Keyboard shortcuts (iOS-like Enter to confirm)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode.kind !== 'exercise') return
|
if (mode.kind !== 'exercise') return
|
||||||
const ex = mode.exercise
|
const ex = mode.exercise
|
||||||
@@ -120,101 +116,71 @@ function ExerciseReminder({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
<div className="reminder-shell flex flex-col h-full">
|
||||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
<div className="titlebar-drag h-8 px-2 flex items-center justify-end">
|
||||||
<div className="text-[10px] uppercase tracking-[0.2em] text-accent font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
|
||||||
<Flame size={11} /> Время тренировки
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
|
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-8 text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.6, opacity: 0, rotate: -8 }}
|
initial={{ scale: 0.7, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 200, damping: 16 }}
|
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
|
||||||
className="relative mb-6"
|
className="relative mb-6"
|
||||||
>
|
>
|
||||||
{/* Outer rotating ring */}
|
<div className="w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-[0_8px_30px_-8px_rgb(var(--accent)/0.5)]">
|
||||||
<motion.div
|
<Icon name={exercise.icon} size={44} strokeWidth={2} />
|
||||||
className="absolute -inset-3 rounded-full"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'conic-gradient(from 0deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 50%, rgb(var(--accent)) 100%)'
|
|
||||||
}}
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
|
|
||||||
/>
|
|
||||||
<div className="absolute -inset-3 rounded-full bg-surface m-[3px]" />
|
|
||||||
<div className="absolute inset-0 rounded-full bg-accent/40 blur-2xl animate-pulse-ring" />
|
|
||||||
<div className="relative w-28 h-28 rounded-full bg-gradient-brand text-white grid place-items-center shadow-glow-lg">
|
|
||||||
<Icon name={exercise.icon} size={48} />
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold">
|
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
|
||||||
Двигайся
|
Время тренировки
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide">
|
<h1 className="font-serif text-[28px] leading-tight tracking-tight mt-2 mb-3 font-medium">
|
||||||
{exercise.name}
|
{exercise.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* HUD reps counter */}
|
<div className="inline-flex items-baseline gap-2 font-mono-num">
|
||||||
<div className="inline-flex items-baseline gap-2 px-5 py-2 rounded-2xl border border-accent/30 bg-accent/10 shadow-glow">
|
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
|
||||||
<Zap size={16} className="text-xp" />
|
|
||||||
<span className="font-mono-num font-bold text-5xl text-gradient-brand leading-none">
|
|
||||||
{exercise.reps}
|
{exercise.reps}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-display font-semibold text-muted uppercase tracking-widest">
|
<span className="text-[15px] text-text/65 font-semibold">раз</span>
|
||||||
REPS
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
|
|
||||||
<Clock size={10} />
|
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
||||||
|
<Clock size={12} strokeWidth={2.4} />
|
||||||
Следующее через {formatInterval(exercise.intervalMinutes)}
|
Следующее через {formatInterval(exercise.intervalMinutes)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
|
{/* iOS action sheet — buttons stacked vertically, equal width */}
|
||||||
<button
|
<div className="px-4 pb-4 space-y-2">
|
||||||
onClick={skip}
|
|
||||||
title="Esc"
|
|
||||||
className="group h-12 rounded-xl bg-surface-elevated hover:bg-defeat/15 hover:text-defeat text-muted text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<X size={14} /> Пропустить
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
|
|
||||||
ESC
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={snooze}
|
|
||||||
title="Space"
|
|
||||||
className="group h-12 rounded-xl bg-surface-elevated hover:bg-surface-elevated/80 text-text text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 border border-border/60 hover:border-accent/40 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Clock size={14} /> Отложить {snoozeMinutes}м
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
|
|
||||||
SPACE
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={done}
|
onClick={done}
|
||||||
title="Enter"
|
className="w-full h-12 rounded-2xl bg-accent text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
className="group h-12 rounded-xl bg-gradient-victory text-white text-sm font-bold uppercase tracking-wide inline-flex flex-col items-center justify-center gap-0.5 shadow-glow-victory hover:brightness-110 transition-all"
|
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<Check size={17} strokeWidth={2.5} /> Готово
|
||||||
<Check size={16} /> Сделал
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={snooze}
|
||||||
|
className="h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
<Clock size={15} strokeWidth={2.5} /> {snoozeMinutes} мин
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
Пропустить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -239,71 +205,59 @@ function MatchSummaryView({
|
|||||||
const won = summary.won === true
|
const won = summary.won === true
|
||||||
const lost = summary.won === false
|
const lost = summary.won === false
|
||||||
|
|
||||||
const heroGradient = won
|
|
||||||
? 'bg-gradient-victory'
|
|
||||||
: lost
|
|
||||||
? 'bg-gradient-defeat'
|
|
||||||
: 'bg-gradient-brand'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
<div className="reminder-shell flex flex-col h-full">
|
||||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
<div className="titlebar-drag h-9 px-2 flex items-center justify-between">
|
||||||
<div className="text-[10px] uppercase tracking-[0.2em] text-muted font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
<div className="text-[12px] text-text/65 font-semibold inline-flex items-center gap-1.5 px-2">
|
||||||
<Gamepad2 size={12} /> {summary.gameName}
|
<Gamepad2 size={12} strokeWidth={2.4} /> {summary.gameName}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pt-2 pb-4 text-center">
|
<div className="px-5 pt-1 pb-4 text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.7, opacity: 0 }}
|
initial={{ scale: 0.7, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||||
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3"
|
className={[
|
||||||
|
'inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-3 text-white',
|
||||||
|
won ? 'bg-success' : lost ? 'bg-destructive' : 'bg-accent'
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className={`absolute inset-0 rounded-2xl ${heroGradient} blur-md opacity-70`} />
|
{won ? (
|
||||||
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}>
|
<Trophy size={26} strokeWidth={2} />
|
||||||
{won ? (
|
) : lost ? (
|
||||||
<Trophy size={30} />
|
<Frown size={26} strokeWidth={2} />
|
||||||
) : lost ? (
|
) : (
|
||||||
<Skull size={30} />
|
<Gamepad2 size={26} strokeWidth={2} />
|
||||||
) : (
|
)}
|
||||||
<Gamepad2 size={30} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
|
<h1 className="font-serif text-[24px] tracking-tight font-medium">
|
||||||
{won
|
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
|
||||||
? 'Победа · тренировка заработана'
|
|
||||||
: lost
|
|
||||||
? 'Проигрыш · но тело не сдаётся'
|
|
||||||
: 'Матч завершён'}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-muted mt-1.5">
|
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
|
||||||
<span className="font-mono-num font-semibold text-text">
|
<span className="font-mono-num font-bold text-text">
|
||||||
{Math.floor(summary.durationMs / 60_000)}
|
{Math.floor(summary.durationMs / 60_000)}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
мин · {summary.results.length}{' '}
|
мин · {summary.results.length} челлендж
|
||||||
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<span className="text-victory font-semibold uppercase tracking-wider">
|
<span className="text-success font-bold">всё готово</span>
|
||||||
готово
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-accent font-bold font-mono-num">
|
<span className="text-accent font-mono-num font-bold">
|
||||||
{remainingReps} осталось
|
{remainingReps} осталось
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 space-y-2 pb-2">
|
<div className="flex-1 overflow-y-auto px-3 space-y-1.5 pb-2">
|
||||||
{summary.results.map((r) => (
|
{summary.results.map((r) => (
|
||||||
<ChallengeRow
|
<ChallengeRow
|
||||||
key={r.challengeId}
|
key={r.challengeId}
|
||||||
@@ -314,10 +268,10 @@ function MatchSummaryView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40">
|
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
|
||||||
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold">
|
<div className="flex-1 text-[13px] text-text/65 font-medium">
|
||||||
Всего ·{' '}
|
Всего ·{' '}
|
||||||
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
|
<span className="text-text font-mono-num font-bold text-[16px]">
|
||||||
{totalReps}
|
{totalReps}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
повторов
|
повторов
|
||||||
@@ -325,13 +279,13 @@ function MatchSummaryView({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={[
|
className={[
|
||||||
'h-11 px-5 rounded-xl text-white text-sm font-bold uppercase tracking-wider inline-flex items-center gap-1.5 transition-all hover:brightness-110',
|
'h-11 px-5 rounded-2xl text-white text-[14px] font-semibold inline-flex items-center gap-1.5 active:scale-[0.98] transition-all',
|
||||||
allDone ? 'bg-gradient-victory shadow-glow-victory' : 'bg-gradient-brand shadow-glow'
|
allDone ? 'bg-success' : 'bg-accent'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<>
|
<>
|
||||||
<Check size={16} /> Готово
|
<Check size={14} strokeWidth={2.5} /> Закрыть
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Позже'
|
'Позже'
|
||||||
@@ -357,41 +311,38 @@ function ChallengeRow({
|
|||||||
initial={{ opacity: 0, x: -8 }}
|
initial={{ opacity: 0, x: -8 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
|
'flex items-center gap-3 rounded-2xl p-3 transition-colors',
|
||||||
done
|
done ? 'bg-success/10' : 'bg-surface-2'
|
||||||
? 'border-victory/40 bg-victory/10'
|
|
||||||
: 'border-border/70 bg-surface-elevated/60'
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
|
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||||
done ? 'bg-victory/20 text-victory' : 'bg-accent/15 text-accent'
|
done ? 'bg-success text-white' : 'bg-accent text-white'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={result.icon} size={22} />
|
<Icon name={result.icon} size={19} strokeWidth={2.2} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'font-display font-semibold tracking-wide truncate',
|
'text-[15px] font-semibold truncate',
|
||||||
done ? 'line-through opacity-60' : ''
|
done ? 'line-through opacity-55' : ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{result.exerciseName}
|
{result.exerciseName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted mt-0.5">
|
<div className="text-[13px] text-text/65 mt-0.5 font-medium">
|
||||||
<span className="font-mono-num font-bold text-text">
|
<span className="font-mono-num font-bold text-text">
|
||||||
{result.statValue}
|
{result.statValue}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{result.statLabel} →{' '}
|
{result.statLabel} → <span>{result.name}</span>
|
||||||
<span className="text-accent">{result.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'font-mono-num text-2xl font-bold tabular-nums',
|
'font-mono-num text-[20px] font-semibold tracking-tight',
|
||||||
done ? 'text-victory' : 'text-gradient-brand'
|
done ? 'text-success' : 'text-accent'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{result.reps}
|
{result.reps}
|
||||||
@@ -400,14 +351,14 @@ function ChallengeRow({
|
|||||||
onClick={onMarkDone}
|
onClick={onMarkDone}
|
||||||
disabled={done}
|
disabled={done}
|
||||||
className={[
|
className={[
|
||||||
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
|
'h-9 w-9 grid place-items-center rounded-full transition-all',
|
||||||
done
|
done
|
||||||
? 'bg-victory text-white cursor-default'
|
? 'bg-success text-white cursor-default'
|
||||||
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow'
|
: 'bg-accent text-white active:scale-90'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-label="Готово"
|
aria-label="Готово"
|
||||||
>
|
>
|
||||||
<Check size={16} />
|
<Check size={15} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Check, Pencil, Trash2, Zap } from 'lucide-react'
|
import { Check, MoreHorizontal } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import type { Exercise, Tick } from '@shared/types'
|
import type { Exercise, Tick } from '@shared/types'
|
||||||
import { Icon } from '../lib/icon'
|
import { Icon } from '../lib/icon'
|
||||||
import { formatCountdown, formatInterval } from '../lib/format'
|
import { formatCountdown, formatInterval } from '../lib/format'
|
||||||
@@ -14,6 +15,11 @@ type Props = {
|
|||||||
onMarkDone: () => void
|
onMarkDone: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS-flavoured exercise card. White surface, soft shadow, big readable
|
||||||
|
* countdown. A subtle ring around the icon shows interval progress —
|
||||||
|
* Apple Fitness ring spirit but minimalist.
|
||||||
|
*/
|
||||||
export function ExerciseCard({
|
export function ExerciseCard({
|
||||||
exercise,
|
exercise,
|
||||||
tick,
|
tick,
|
||||||
@@ -27,8 +33,9 @@ export function ExerciseCard({
|
|||||||
const remaining = Math.max(0, Math.min(total, ms))
|
const remaining = Math.max(0, Math.min(total, ms))
|
||||||
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
||||||
const isDue = ms <= 0 && exercise.enabled
|
const isDue = ms <= 0 && exercise.enabled
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
// SVG cooldown ring math
|
// Ring math
|
||||||
const R = 22
|
const R = 22
|
||||||
const C = 2 * Math.PI * R
|
const C = 2 * Math.PI * R
|
||||||
const dashOffset = C * (1 - elapsedPct)
|
const dashOffset = C * (1 - elapsedPct)
|
||||||
@@ -36,145 +43,140 @@ export function ExerciseCard({
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 6 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.97 }}
|
||||||
whileHover={{ y: -2 }}
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
|
className="relative bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"
|
||||||
className={[
|
|
||||||
'group relative rounded-2xl border bg-surface/80 backdrop-blur-sm p-5 flex flex-col gap-4',
|
|
||||||
'transition-shadow',
|
|
||||||
isDue
|
|
||||||
? 'neon-border hud-pulse border-transparent'
|
|
||||||
: 'border-border/70 hover:border-accent/40 hover:shadow-soft'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
{/* Glow corner accent */}
|
<div className="flex items-start gap-4">
|
||||||
<div
|
{/* Icon + progress ring */}
|
||||||
className={[
|
<div className="relative w-14 h-14 shrink-0">
|
||||||
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity',
|
<svg
|
||||||
exercise.enabled
|
className="absolute inset-0 -rotate-90"
|
||||||
? 'bg-accent/15 opacity-100'
|
viewBox="0 0 56 56"
|
||||||
: 'bg-muted/10 opacity-50'
|
width="56"
|
||||||
].join(' ')}
|
height="56"
|
||||||
/>
|
>
|
||||||
|
<circle
|
||||||
<div className="relative flex items-start justify-between gap-3">
|
cx="28"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
cy="28"
|
||||||
{/* Hex-like rounded icon plaque with cooldown ring */}
|
r={R}
|
||||||
<div className="relative w-14 h-14 shrink-0">
|
fill="none"
|
||||||
<svg
|
strokeWidth="2.5"
|
||||||
className="absolute inset-0 -rotate-90"
|
className="stroke-hairline/15 dark:stroke-hairline/30"
|
||||||
viewBox="0 0 56 56"
|
/>
|
||||||
width="56"
|
{exercise.enabled && (
|
||||||
height="56"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="cooldownGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor="rgb(var(--accent))" />
|
|
||||||
<stop offset="100%" stopColor="rgb(var(--accent-2))" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle
|
<circle
|
||||||
className="cooldown-track"
|
|
||||||
cx="28"
|
cx="28"
|
||||||
cy="28"
|
cy="28"
|
||||||
r={R}
|
r={R}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeWidth="3"
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={C}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
className={
|
||||||
|
isDue ? 'stroke-accent' : 'stroke-accent/85'
|
||||||
|
}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
|
||||||
/>
|
/>
|
||||||
{exercise.enabled && (
|
)}
|
||||||
<circle
|
</svg>
|
||||||
className="cooldown-fill"
|
<div
|
||||||
cx="28"
|
|
||||||
cy="28"
|
|
||||||
r={R}
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeDasharray={C}
|
|
||||||
strokeDashoffset={dashOffset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'absolute inset-[7px] rounded-full grid place-items-center transition-colors',
|
|
||||||
exercise.enabled
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'bg-surface-elevated text-muted'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<Icon name={exercise.icon} size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-semibold leading-tight truncate font-display text-lg tracking-wide">
|
|
||||||
{exercise.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5">
|
|
||||||
<Zap size={11} className="text-xp" />
|
|
||||||
<span className="font-mono-num font-semibold text-text">
|
|
||||||
{exercise.reps}
|
|
||||||
</span>
|
|
||||||
<span>повторов · каждые {formatInterval(exercise.intervalMinutes)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={exercise.enabled}
|
|
||||||
onChange={onToggle}
|
|
||||||
aria-label="Включить/выключить"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-baseline justify-between mb-1.5">
|
|
||||||
<span className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
|
||||||
Через
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={[
|
className={[
|
||||||
'text-sm font-mono-num font-bold',
|
'absolute inset-[8px] rounded-full grid place-items-center',
|
||||||
isDue ? 'text-accent' : 'text-text'
|
exercise.enabled
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'bg-surface-2 text-text/40'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
|
<Icon name={exercise.icon} size={20} />
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
|
|
||||||
<motion.div
|
<div className="flex-1 min-w-0">
|
||||||
className={[
|
<div className="flex items-center justify-between gap-2">
|
||||||
'h-full rounded-full',
|
<h3 className="font-display text-[18px] font-bold leading-tight truncate">
|
||||||
isDue ? 'bg-gradient-brand' : 'bg-accent'
|
{exercise.name}
|
||||||
].join(' ')}
|
</h3>
|
||||||
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }}
|
<div className="relative">
|
||||||
transition={{ duration: 0.5, ease: 'linear' }}
|
<button
|
||||||
/>
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
|
||||||
|
aria-label="Меню"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
onEdit()
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
||||||
|
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown + switch */}
|
||||||
|
<div className="flex items-end justify-between mt-3.5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
|
||||||
|
{isDue ? 'Сейчас' : 'Через'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
|
||||||
|
isDue ? 'text-accent' : 'text-text'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={exercise.enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
aria-label="Включить/выключить"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-2 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
|
{/* Done action — appears as filled pill at bottom only on due */}
|
||||||
<button
|
{isDue && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
onClick={onMarkDone}
|
onClick={onMarkDone}
|
||||||
className="flex-1 h-9 rounded-lg bg-victory/15 hover:bg-victory/25 text-victory text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-colors"
|
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
<Check size={14} /> Сделал
|
<Check size={15} strokeWidth={2.5} /> Готово
|
||||||
</button>
|
</motion.button>
|
||||||
<button
|
)}
|
||||||
onClick={onEdit}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text transition-colors"
|
|
||||||
aria-label="Редактировать"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
|
||||||
aria-label="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Zap } from 'lucide-react'
|
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise } from '@shared/types'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -56,10 +55,10 @@ export function ExerciseEditor({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={exercise ? 'Редактировать упражнение' : 'Новое упражнение'}
|
title={exercise ? 'Редактировать' : 'Новое упражнение'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||||
@@ -69,28 +68,17 @@ export function ExerciseEditor({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Preview card */}
|
{/* Live preview header */}
|
||||||
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden">
|
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
|
||||||
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
|
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
|
||||||
<div className="relative flex items-center gap-4">
|
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
|
||||||
<div className="relative w-14 h-14 shrink-0">
|
</div>
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
|
<div className="min-w-0">
|
||||||
<div className="relative w-14 h-14 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
|
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
|
||||||
<Icon name={draft.icon} size={26} />
|
{draft.name || 'Без названия'}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
{draft.reps} раз · каждые {draft.intervalMinutes} мин
|
||||||
Preview
|
|
||||||
</div>
|
|
||||||
<div className="font-display font-bold text-lg uppercase tracking-wide truncate">
|
|
||||||
{draft.name || 'Без названия'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5 font-mono-num">
|
|
||||||
<Zap size={11} className="text-xp" />
|
|
||||||
<span className="font-bold text-text">{draft.reps}</span>
|
|
||||||
<span>повторов · каждые {draft.intervalMinutes} мин</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,22 +87,25 @@ export function ExerciseEditor({
|
|||||||
<input
|
<input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
placeholder="Например, приседания"
|
placeholder="Приседания"
|
||||||
className="input"
|
className="ios-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Повторений">
|
<Field label="Повторений">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={draft.reps}
|
value={draft.reps}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({ ...draft, reps: Math.max(1, Number(e.target.value) || 1) })
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
reps: Math.max(1, Number(e.target.value) || 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="input font-mono-num font-semibold"
|
className="ios-input font-mono-num"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Интервал (мин)">
|
<Field label="Интервал (мин)">
|
||||||
@@ -128,47 +119,47 @@ export function ExerciseEditor({
|
|||||||
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
|
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="input font-mono-num font-semibold"
|
className="ios-input font-mono-num"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Иконка">
|
<Field label="Иконка">
|
||||||
<div className="grid grid-cols-9 gap-2 max-h-48 overflow-y-auto pr-1">
|
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||||
{ICON_CHOICES.map((name) => (
|
{ICON_CHOICES.map((name) => (
|
||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDraft({ ...draft, icon: name })}
|
onClick={() => setDraft({ ...draft, icon: name })}
|
||||||
className={[
|
className={[
|
||||||
'h-10 w-10 grid place-items-center rounded-lg border transition-all',
|
'h-10 w-10 grid place-items-center rounded-xl transition-all active:scale-90',
|
||||||
draft.icon === name
|
draft.icon === name
|
||||||
? 'border-accent bg-accent/15 text-accent shadow-glow scale-105'
|
? 'bg-accent text-white'
|
||||||
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
|
: 'bg-surface text-text/65 hover:text-text'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={name} size={18} />
|
<Icon name={name} size={17} strokeWidth={2.2} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.input {
|
.ios-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgb(var(--border));
|
border: 0;
|
||||||
background: rgb(var(--surface-elevated));
|
background: rgb(var(--surface-2));
|
||||||
color: rgb(var(--text));
|
color: rgb(var(--text));
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
transition: box-shadow .15s ease;
|
||||||
}
|
}
|
||||||
.input:focus {
|
.ios-input:focus {
|
||||||
border-color: rgb(var(--accent));
|
box-shadow: 0 0 0 2px rgb(var(--accent) / 0.45);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
|
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -184,7 +175,7 @@ function Field({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]">
|
<span className="block text-[12px] font-medium text-text/55 mb-1.5">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Sun,
|
||||||
ListChecks,
|
|
||||||
Gamepad2,
|
|
||||||
Target,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
|
Joystick,
|
||||||
|
Flame,
|
||||||
|
Settings2,
|
||||||
X
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const links = [
|
type Item = {
|
||||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboard, end: true },
|
to: string
|
||||||
{ to: '/exercises', label: 'Упражнения', icon: ListChecks },
|
label: string
|
||||||
{ to: '/games', label: 'Игры', icon: Gamepad2 },
|
icon: typeof Sun
|
||||||
{ to: '/challenges', label: 'Челленджи', icon: Target },
|
end?: boolean
|
||||||
{ to: '/settings', label: 'Настройки', icon: SettingsIcon }
|
tint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tinted icon plaques á la iOS Settings rows.
|
||||||
|
const items: Item[] = [
|
||||||
|
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
|
||||||
|
{
|
||||||
|
to: '/exercises',
|
||||||
|
label: 'Упражнения',
|
||||||
|
icon: Dumbbell,
|
||||||
|
tint: 'bg-info'
|
||||||
|
},
|
||||||
|
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
|
||||||
|
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
|
||||||
|
{
|
||||||
|
to: '/settings',
|
||||||
|
label: 'Настройки',
|
||||||
|
icon: Settings2,
|
||||||
|
tint: 'bg-text/70'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -23,12 +41,15 @@ type Props = {
|
|||||||
onMobileClose?: () => void
|
onMobileClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Element {
|
export function Sidebar({
|
||||||
|
mobileOpen = false,
|
||||||
|
onMobileClose
|
||||||
|
}: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar: hidden on <md, icon-only on md, full on lg+ */}
|
{/* Desktop sidebar — macOS vibrancy panel */}
|
||||||
<aside className="hidden md:flex w-16 lg:w-60 shrink-0 border-r border-border/60 bg-surface/40 backdrop-blur-sm flex-col relative">
|
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
|
||||||
<SidebarContent compact />
|
<SidebarContent />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
@@ -39,29 +60,30 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/30 backdrop-blur-md"
|
||||||
onClick={onMobileClose}
|
onClick={onMobileClose}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
/>
|
/>
|
||||||
<motion.aside
|
<motion.aside
|
||||||
className="relative w-64 max-w-[80vw] h-full border-r border-border/60 bg-surface flex flex-col"
|
className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col"
|
||||||
initial={{ x: '-100%' }}
|
initial={{ x: '-100%' }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: '-100%' }}
|
exit={{ x: '-100%' }}
|
||||||
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
transition={{ type: 'spring', stiffness: 420, damping: 38 }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onMobileClose}
|
onClick={onMobileClose}
|
||||||
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted transition-colors"
|
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
|
||||||
aria-label="Закрыть меню"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={14} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
<SidebarContent compact={false} onNav={onMobileClose} />
|
<SidebarContent onNav={onMobileClose} />
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -70,84 +92,52 @@ export function Sidebar({ mobileOpen = false, onMobileClose }: Props): JSX.Eleme
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({
|
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
|
||||||
compact,
|
|
||||||
onNav
|
|
||||||
}: {
|
|
||||||
compact: boolean
|
|
||||||
onNav?: () => void
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="relative px-3 lg:px-5 py-5">
|
<div className="px-5 pt-7 pb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="font-serif text-[34px] leading-none tracking-tight font-medium">
|
||||||
<div className="relative shrink-0">
|
Laude
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
|
</div>
|
||||||
<div className="relative w-11 h-11 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
|
<div className="text-[12px] text-text/45 mt-2 tracking-tight">
|
||||||
<Dumbbell size={20} strokeWidth={2.5} />
|
Двигайся осознанно
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'min-w-0',
|
|
||||||
compact ? 'hidden lg:block' : 'block'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="font-display font-bold text-lg leading-none uppercase tracking-wider">
|
|
||||||
<span className="text-gradient-brand">Laude</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted uppercase tracking-[0.18em] mt-1">
|
|
||||||
Move every day
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative px-3 lg:px-5 pb-3">
|
|
||||||
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="relative px-2 lg:px-3 flex flex-col gap-0.5">
|
<nav className="px-2.5 flex flex-col gap-1">
|
||||||
{links.map(({ to, label, icon: Icon, end }) => (
|
{items.map(({ to, label, icon: Icon, end, tint }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
end={end}
|
end={end}
|
||||||
onClick={onNav}
|
onClick={onNav}
|
||||||
title={compact ? label : undefined}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
'group relative flex items-center gap-3 px-2.5 lg:px-3 py-2.5 rounded-xl text-sm font-medium transition-all',
|
'flex items-center gap-3 px-2.5 py-2 rounded-xl transition-colors duration-150',
|
||||||
compact ? 'justify-center lg:justify-start' : '',
|
|
||||||
isActive
|
isActive
|
||||||
? 'text-text bg-surface-elevated/80'
|
? 'bg-text/[0.06] dark:bg-white/[0.08]'
|
||||||
: 'text-muted hover:text-text hover:bg-surface-elevated/50'
|
: 'hover:bg-text/[0.04] dark:hover:bg-white/[0.04]'
|
||||||
].join(' ')
|
].join(' ')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all',
|
'w-8 h-8 rounded-[9px] grid place-items-center text-white shrink-0',
|
||||||
isActive
|
tint ?? 'bg-text/70'
|
||||||
? 'bg-gradient-to-b from-accent to-accent-2 opacity-100 shadow-glow'
|
|
||||||
: 'opacity-0'
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
>
|
||||||
<Icon
|
<Icon size={17} strokeWidth={2.2} />
|
||||||
size={18}
|
</div>
|
||||||
className={isActive ? 'text-accent' : ''}
|
|
||||||
strokeWidth={isActive ? 2.4 : 2}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
compact ? 'hidden lg:inline' : 'inline',
|
'text-[15px] truncate',
|
||||||
isActive ? 'font-semibold' : ''
|
isActive
|
||||||
|
? 'text-text font-semibold'
|
||||||
|
: 'text-text/85 font-medium'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -158,26 +148,14 @@ function SidebarContent({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Status footer — hidden on icon-only desktop */}
|
{/* Status footer */}
|
||||||
<div
|
<div className="mt-auto px-5 pb-5">
|
||||||
className={[
|
<div className="flex items-center gap-2 text-[11px] text-text/45">
|
||||||
'relative mt-auto p-4',
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
compact ? 'hidden lg:block' : 'block'
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
||||||
].join(' ')}
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
||||||
>
|
</span>
|
||||||
<div className="rounded-xl border border-border/60 bg-surface-elevated/60 p-3">
|
Активность отслеживается
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-60 animate-ping" />
|
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-victory" />
|
|
||||||
</span>
|
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted">
|
|
||||||
Online
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted/80 leading-snug">
|
|
||||||
Трекинг активности включён
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,61 +1,78 @@
|
|||||||
import { Minus, X, Square, Activity, Menu } from 'lucide-react'
|
import { Minus, X, Square, Menu } from 'lucide-react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string
|
title: string
|
||||||
onMenuClick?: () => void
|
onMenuClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS-style translucent titlebar. Title centred small, no app icon.
|
||||||
|
* Window buttons sit right; a left-side hamburger surfaces on mobile only.
|
||||||
|
*/
|
||||||
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="titlebar-drag relative h-10 px-2 sm:px-4 flex items-center justify-between border-b border-border/60 bg-surface/50 backdrop-blur-md hud-scanlines">
|
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
|
||||||
<div className="flex items-center gap-2">
|
{/* Left: hamburger only on small */}
|
||||||
{/* Mobile menu — only visible on <md (where sidebar is hidden) */}
|
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text transition-colors"
|
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 transition-colors"
|
||||||
aria-label="Открыть меню"
|
aria-label="Меню"
|
||||||
>
|
>
|
||||||
<Menu size={15} />
|
<Menu size={15} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs font-medium">
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute inset-0 rounded-full bg-accent blur-[6px] opacity-70" />
|
|
||||||
<Activity
|
|
||||||
size={12}
|
|
||||||
className="relative text-accent"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="hidden sm:inline uppercase tracking-[0.18em] text-muted font-display font-semibold">
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="titlebar-nodrag flex items-center gap-1">
|
|
||||||
<button
|
{/* Centre title */}
|
||||||
onClick={() => window.api.minimizeMain()}
|
<div className="text-[12px] font-medium text-text/55 truncate px-2">
|
||||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
|
{title}
|
||||||
aria-label="Свернуть"
|
</div>
|
||||||
>
|
|
||||||
<Minus size={14} />
|
{/* Right window controls */}
|
||||||
</button>
|
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
||||||
<button
|
<WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
|
||||||
onClick={() => window.api.hideMain()}
|
<Minus size={13} strokeWidth={2} />
|
||||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
|
</WinBtn>
|
||||||
aria-label="Скрыть в трей"
|
<WinBtn onClick={() => window.api.hideMain()} label="В трей">
|
||||||
>
|
<Square size={11} strokeWidth={2} />
|
||||||
<Square size={12} />
|
</WinBtn>
|
||||||
</button>
|
<WinBtn
|
||||||
<button
|
|
||||||
onClick={() => window.api.closeMain()}
|
onClick={() => window.api.closeMain()}
|
||||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white transition-colors text-muted"
|
label="Закрыть"
|
||||||
aria-label="Закрыть"
|
danger
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={13} strokeWidth={2} />
|
||||||
</button>
|
</WinBtn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WinBtn({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
danger = false
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
danger?: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
className={[
|
||||||
|
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
|
||||||
|
danger
|
||||||
|
? 'hover:bg-destructive hover:text-white'
|
||||||
|
: 'hover:bg-text/[0.08] hover:text-text'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
|
import { Card } from './ui/Card'
|
||||||
import type { UpdaterStatus } from '@shared/types'
|
import type { UpdaterStatus } from '@shared/types'
|
||||||
|
|
||||||
export function UpdaterCard(): JSX.Element {
|
export function UpdaterCard(): JSX.Element {
|
||||||
@@ -28,7 +29,6 @@ export function UpdaterCard(): JSX.Element {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function download(): Promise<void> {
|
async function download(): Promise<void> {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -37,25 +37,20 @@ export function UpdaterCard(): JSX.Element {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function install(): void {
|
function install(): void {
|
||||||
void window.api.updaterInstall()
|
void window.api.updaterInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-7">
|
<Card>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<Body
|
||||||
<span className="w-7 h-7 rounded-lg bg-accent/15 text-accent grid place-items-center">
|
status={status}
|
||||||
<PackageCheck size={14} />
|
busy={busy}
|
||||||
</span>
|
onCheck={check}
|
||||||
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold">
|
onDownload={download}
|
||||||
Обновления
|
onInstall={install}
|
||||||
</h2>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
<div className="relative rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
|
||||||
<Body status={status} busy={busy} onCheck={check} onDownload={download} onInstall={install} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +69,9 @@ function Body({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
if (status.kind === 'unsupported') {
|
if (status.kind === 'unsupported') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="muted"
|
tone="muted"
|
||||||
icon={<AlertTriangle size={18} />}
|
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
||||||
title="Auto-update недоступен"
|
title="Auto-update недоступен"
|
||||||
subtitle={status.reason}
|
subtitle={status.reason}
|
||||||
/>
|
/>
|
||||||
@@ -84,23 +79,23 @@ function Body({
|
|||||||
}
|
}
|
||||||
if (status.kind === 'checking') {
|
if (status.kind === 'checking') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="accent"
|
tone="info"
|
||||||
icon={<RefreshCw size={18} className="animate-spin" />}
|
icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
|
||||||
title="Проверяем наличие обновлений…"
|
title="Проверяем обновления…"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (status.kind === 'not-available') {
|
if (status.kind === 'not-available') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="victory"
|
tone="success"
|
||||||
icon={<CheckCircle2 size={18} />}
|
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
||||||
title="Установлена последняя версия"
|
title="Последняя версия"
|
||||||
subtitle={`Текущая: v${status.currentVersion}`}
|
subtitle={`Текущая: v${status.currentVersion}`}
|
||||||
action={
|
action={
|
||||||
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
|
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={14} /> Проверить
|
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -108,14 +103,18 @@ function Body({
|
|||||||
}
|
}
|
||||||
if (status.kind === 'available') {
|
if (status.kind === 'available') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="accent"
|
tone="accent"
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={16} strokeWidth={2.4} />}
|
||||||
title={`Доступно обновление v${status.version}`}
|
title={`Доступна v${status.version}`}
|
||||||
subtitle={status.releaseDate ? new Date(status.releaseDate).toLocaleString('ru-RU') : undefined}
|
subtitle={
|
||||||
|
status.releaseDate
|
||||||
|
? new Date(status.releaseDate).toLocaleString('ru-RU')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={onDownload} disabled={busy}>
|
<Button size="sm" onClick={onDownload} disabled={busy}>
|
||||||
<Download size={14} /> Скачать
|
<Download size={13} strokeWidth={2.5} /> Скачать
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -125,27 +124,27 @@ function Body({
|
|||||||
const pct = Math.max(0, Math.min(100, status.percent || 0))
|
const pct = Math.max(0, Math.min(100, status.percent || 0))
|
||||||
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
|
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-4">
|
<div className="px-4 py-4">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center">
|
<div className="w-10 h-10 rounded-xl bg-accent/12 text-accent grid place-items-center">
|
||||||
<Download size={18} />
|
<Download size={17} strokeWidth={2.4} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-display font-semibold text-sm tracking-wide">
|
<div className="text-[15px] font-semibold leading-tight">
|
||||||
Загружаем обновление…
|
Загружаем обновление
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted mt-0.5 font-mono-num">
|
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
|
||||||
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
|
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
|
||||||
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
|
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono-num font-bold text-lg text-accent">
|
<div className="font-mono-num font-bold text-[18px] text-accent">
|
||||||
{pct.toFixed(0)}%
|
{pct.toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
|
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full bg-gradient-brand"
|
className="h-full bg-accent"
|
||||||
animate={{ width: `${pct}%` }}
|
animate={{ width: `${pct}%` }}
|
||||||
transition={{ duration: 0.3, ease: 'linear' }}
|
transition={{ duration: 0.3, ease: 'linear' }}
|
||||||
/>
|
/>
|
||||||
@@ -155,13 +154,13 @@ function Body({
|
|||||||
}
|
}
|
||||||
if (status.kind === 'downloaded') {
|
if (status.kind === 'downloaded') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="victory"
|
tone="success"
|
||||||
icon={<CheckCircle2 size={18} />}
|
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
||||||
title={`Готово · v${status.version} загружена`}
|
title={`Готово · v${status.version}`}
|
||||||
subtitle="Перезапустите приложение для применения"
|
subtitle="Перезапусти для применения"
|
||||||
action={
|
action={
|
||||||
<Button variant="victory" size="sm" onClick={onInstall}>
|
<Button variant="filled" size="sm" onClick={onInstall}>
|
||||||
Перезапустить
|
Перезапустить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -170,70 +169,70 @@ function Body({
|
|||||||
}
|
}
|
||||||
if (status.kind === 'error') {
|
if (status.kind === 'error') {
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="defeat"
|
tone="destructive"
|
||||||
icon={<AlertTriangle size={18} />}
|
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
||||||
title="Ошибка проверки обновлений"
|
title="Ошибка проверки"
|
||||||
subtitle={status.message}
|
subtitle={status.message}
|
||||||
action={
|
action={
|
||||||
<Button variant="secondary" size="sm" onClick={onCheck} disabled={busy}>
|
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={14} /> Повторить
|
<RefreshCw size={13} strokeWidth={2.5} /> Повторить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// idle
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Cell
|
||||||
tone="muted"
|
tone="muted"
|
||||||
icon={<PackageCheck size={18} />}
|
icon={<PackageCheck size={16} strokeWidth={2.4} />}
|
||||||
title="Проверить наличие обновлений"
|
title="Проверить обновления"
|
||||||
subtitle="Авто-проверка раз в 6 часов"
|
subtitle="Авто-проверка раз в 6 часов"
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={onCheck} disabled={busy}>
|
<Button size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={14} /> Проверить
|
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Cell({
|
||||||
tone,
|
tone,
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
action
|
action
|
||||||
}: {
|
}: {
|
||||||
tone: 'accent' | 'victory' | 'defeat' | 'muted'
|
tone: 'accent' | 'info' | 'success' | 'destructive' | 'muted'
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const toneClasses = {
|
const cls = {
|
||||||
accent: 'bg-accent/15 text-accent',
|
accent: 'bg-accent/12 text-accent',
|
||||||
victory: 'bg-victory/15 text-victory',
|
info: 'bg-info/12 text-info',
|
||||||
defeat: 'bg-defeat/15 text-defeat',
|
success: 'bg-success/15 text-success',
|
||||||
muted: 'bg-surface-elevated text-muted'
|
destructive: 'bg-destructive/12 text-destructive',
|
||||||
|
muted: 'bg-surface-2 text-text/55'
|
||||||
}[tone]
|
}[tone]
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-5 py-4">
|
<div className="flex items-center gap-3 px-4 py-4">
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||||
toneClasses
|
cls
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-display font-semibold text-sm tracking-wide">
|
<div className="text-[15px] font-semibold leading-tight">{title}</div>
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div className="text-xs text-muted mt-0.5 truncate">{subtitle}</div>
|
<div className="text-[13px] text-text/65 mt-1 truncate font-medium">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{action}
|
{action}
|
||||||
|
|||||||
@@ -1,40 +1,60 @@
|
|||||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
|
/**
|
||||||
|
* iOS-style button. Three primary flavours mirror iOS's filled / tinted / plain.
|
||||||
|
* Press feedback is a subtle scale, mirroring UIKit's button highlight.
|
||||||
|
*/
|
||||||
|
type Variant = 'filled' | 'tinted' | 'plain' | 'destructive' | 'success'
|
||||||
type Size = 'sm' | 'md' | 'lg'
|
type Size = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
// Legacy alias — old pages used 'primary' / 'secondary' / 'ghost' / 'danger' / 'victory'.
|
||||||
|
type LegacyVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
|
||||||
|
|
||||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
variant?: Variant
|
variant?: Variant | LegacyVariant
|
||||||
size?: Size
|
size?: Size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyMap: Record<LegacyVariant, Variant> = {
|
||||||
|
primary: 'filled',
|
||||||
|
secondary: 'tinted',
|
||||||
|
ghost: 'plain',
|
||||||
|
danger: 'destructive',
|
||||||
|
victory: 'success'
|
||||||
|
}
|
||||||
|
|
||||||
const variantClasses: Record<Variant, string> = {
|
const variantClasses: Record<Variant, string> = {
|
||||||
primary:
|
filled:
|
||||||
'bg-gradient-brand text-white shadow-glow hover:shadow-glow-lg hover:brightness-110 active:brightness-95',
|
'bg-accent text-white hover:brightness-105 active:brightness-95',
|
||||||
secondary:
|
tinted:
|
||||||
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border hover:border-accent/40',
|
'bg-accent/12 text-accent hover:bg-accent/18 active:bg-accent/22 dark:bg-accent/20 dark:hover:bg-accent/25',
|
||||||
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
|
plain: 'text-accent hover:bg-accent/10 active:bg-accent/15',
|
||||||
danger: 'bg-defeat text-white hover:brightness-110 shadow-soft',
|
destructive:
|
||||||
victory:
|
'bg-destructive/12 text-destructive hover:bg-destructive/18 active:bg-destructive/22 dark:bg-destructive/20',
|
||||||
'bg-gradient-victory text-white shadow-glow-victory hover:brightness-110'
|
success:
|
||||||
|
'bg-success text-white hover:brightness-105 active:brightness-95'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses: Record<Size, string> = {
|
const sizeClasses: Record<Size, string> = {
|
||||||
sm: 'h-8 px-3 text-xs',
|
sm: 'h-8 px-3.5 text-[13px] rounded-xl',
|
||||||
md: 'h-10 px-4 text-sm',
|
md: 'h-10 px-4 text-[14px] rounded-2xl',
|
||||||
lg: 'h-12 px-6 text-base'
|
lg: 'h-12 px-6 text-[16px] rounded-2xl'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||||
{ variant = 'primary', size = 'md', className = '', ...rest },
|
{ variant = 'filled', size = 'md', className = '', ...rest },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
|
const v: Variant =
|
||||||
|
(legacyMap as Record<string, Variant>)[variant] ?? (variant as Variant)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={[
|
className={[
|
||||||
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold tracking-wide transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
'inline-flex items-center justify-center gap-1.5 font-semibold transition-all duration-150 ease-out',
|
||||||
variantClasses[variant],
|
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||||
|
'active:scale-[0.97]',
|
||||||
|
variantClasses[v],
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
className
|
className
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
|||||||
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'
|
lg: 'max-w-3xl'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS-style centred sheet. Spring-snap on enter, soft fade-out.
|
||||||
|
* Backdrop uses heavy blur for proper iOS modal feel.
|
||||||
|
*/
|
||||||
export function Modal({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -38,59 +42,48 @@ export function Modal({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 grid place-items-center bg-black/60 backdrop-blur-md"
|
className="fixed inset-0 z-50 grid place-items-center p-4 bg-black/40 backdrop-blur-md"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={[
|
className={[
|
||||||
'relative w-full mx-4 bg-surface rounded-2xl border border-accent/25 flex flex-col overflow-hidden',
|
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
|
||||||
sizeClass[size]
|
sizeClass[size]
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{
|
initial={{ scale: 0.94, y: 24, opacity: 0 }}
|
||||||
boxShadow:
|
|
||||||
'0 0 0 1px rgb(var(--accent) / 0.1), 0 24px 80px -20px rgb(var(--accent) / 0.35), 0 28px 60px -20px rgb(0 0 0 / 0.6)'
|
|
||||||
}}
|
|
||||||
initial={{ scale: 0.95, y: 16, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||||
exit={{ scale: 0.96, y: 8, opacity: 0 }}
|
exit={{ scale: 0.96, y: 12, opacity: 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 26 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Glow accent corner */}
|
{/* Header — iOS large modal title */}
|
||||||
<div className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
<div className="absolute -bottom-24 -left-24 w-56 h-56 rounded-full bg-accent-2/15 blur-3xl pointer-events-none" />
|
<h2 className="font-display text-[20px] font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
<div className="relative flex items-center justify-between px-5 py-4">
|
</h2>
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="w-1 h-5 rounded-full bg-gradient-brand" />
|
|
||||||
<h3 className="font-display font-bold text-base uppercase tracking-wider">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted transition-colors"
|
className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={14} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
|
||||||
|
|
||||||
<div className="relative px-5 py-4 overflow-y-auto max-h-[70vh]">
|
<div className="px-5 pb-5 overflow-y-auto max-h-[70vh]">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{footer && (
|
{footer && (
|
||||||
<>
|
<div className="hairline-t px-5 py-3 flex justify-end gap-2 bg-surface">
|
||||||
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
{footer}
|
||||||
<div className="relative px-5 py-4 flex justify-end gap-2">{footer}</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onChange: (next: boolean) => void
|
onChange: (next: boolean) => void
|
||||||
@@ -5,7 +7,15 @@ type Props = {
|
|||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Element {
|
/**
|
||||||
|
* iOS UISwitch — 51×31 spec, green when on, smooth spring knob.
|
||||||
|
*/
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -15,19 +25,20 @@ export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Ele
|
|||||||
onClick={() => !disabled && onChange(!checked)}
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={[
|
className={[
|
||||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
'relative inline-flex h-[31px] w-[51px] shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-out',
|
||||||
checked
|
checked ? 'bg-success' : 'bg-hairline/25 dark:bg-hairline/50',
|
||||||
? 'bg-gradient-brand shadow-glow'
|
disabled ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
: 'bg-surface-elevated border border-border',
|
|
||||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
style={{ padding: 2 }}
|
||||||
>
|
>
|
||||||
<span
|
<motion.span
|
||||||
className={[
|
className="block h-[27px] w-[27px] rounded-full bg-white"
|
||||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-soft transition-transform duration-200',
|
style={{
|
||||||
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
|
boxShadow:
|
||||||
'mt-0.5'
|
'0 3px 8px rgba(0,0,0,0.15), 0 3px 1px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(0,0,0,0.04)'
|
||||||
].join(' ')}
|
}}
|
||||||
|
animate={{ x: checked ? 20 : 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 700, damping: 35 }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import { Plus, ChevronRight, AlertTriangle, Gamepad2 } from 'lucide-react'
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Gamepad2,
|
|
||||||
Target,
|
|
||||||
AlertTriangle
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
import { Modal } from '../components/ui/Modal'
|
import { Modal } from '../components/ui/Modal'
|
||||||
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { ICON_CHOICES, Icon } from '../lib/icon'
|
import { ICON_CHOICES, Icon } from '../lib/icon'
|
||||||
import { GAME_STATS, STAT_LABELS } from '@shared/types'
|
import { GAME_STATS, STAT_LABELS } from '@shared/types'
|
||||||
import type { Challenge, GameId, GameStat, GameStatus } from '@shared/types'
|
import type {
|
||||||
|
Challenge,
|
||||||
|
GameId,
|
||||||
|
GameStat,
|
||||||
|
GameStatus
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
const GAME_NAMES: Record<GameId, string> = {
|
const GAME_NAMES: Record<GameId, string> = {
|
||||||
dota2: 'Dota 2'
|
dota2: 'Dota 2'
|
||||||
@@ -43,147 +41,111 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
return window.api.onGamesChanged(setGames)
|
return window.api.onGamesChanged(setGames)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activeCount = challenges.filter((c) => c.enabled).length
|
const noGamesActive = games.length > 0 && !games.some((g) => g.enabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-3xl">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
<div>
|
||||||
Правила за матч
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
</div>
|
Правила за матч
|
||||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
|
||||||
<span className="text-gradient-brand">Челленджи</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted mt-2">
|
|
||||||
После матча · повторов = <span className="text-text font-mono-num">статистика × коэффициент</span>
|
|
||||||
{activeCount > 0 && (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
<span className="text-accent font-mono-num font-bold">
|
|
||||||
{activeCount} активных
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="self-start sm:self-auto flex-shrink-0"
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(null)
|
|
||||||
setEditorOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={16} /> Новый
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
|
||||||
{challenges.map((c, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={c.id}
|
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: i * 0.03 }}
|
|
||||||
className={[
|
|
||||||
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
|
||||||
c.enabled
|
|
||||||
? 'hover:bg-accent/[0.04]'
|
|
||||||
: 'opacity-70 hover:opacity-100',
|
|
||||||
i < challenges.length - 1 ? 'border-b border-border/40' : ''
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
|
||||||
c.enabled
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'bg-surface-elevated text-muted'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<Icon name={c.icon} size={20} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||||
<div className="font-display font-semibold tracking-wide truncate text-base">
|
Челленджи
|
||||||
{c.name}
|
</h1>
|
||||||
</div>
|
<p className="text-[15px] text-text/65 mt-2 font-medium">
|
||||||
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5 flex-wrap">
|
Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
|
||||||
<Gamepad2 size={11} className="text-muted" />
|
|
||||||
<span>{GAME_NAMES[c.gameId]}</span>
|
|
||||||
<span className="text-border">·</span>
|
|
||||||
<span className="font-mono-num">
|
|
||||||
<span className="text-text font-semibold">
|
|
||||||
{STAT_LABELS[c.stat]}
|
|
||||||
</span>{' '}
|
|
||||||
×{' '}
|
|
||||||
<span className="text-accent font-bold">{c.multiplier}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-border">→</span>
|
|
||||||
<span className="text-text font-semibold">
|
|
||||||
{c.exerciseName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={c.enabled}
|
|
||||||
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(c)
|
|
||||||
setEditorOpen(true)
|
|
||||||
}}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text transition-colors"
|
|
||||||
aria-label="Редактировать"
|
|
||||||
>
|
|
||||||
<Pencil size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.api.deleteChallenge(c.id)}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
|
||||||
aria-label="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
{challenges.length === 0 && (
|
|
||||||
<div className="px-5 py-16 text-center">
|
|
||||||
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
|
|
||||||
<Target size={26} />
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-lg font-semibold uppercase tracking-wider">
|
|
||||||
Челленджей нет
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted mt-1">
|
|
||||||
Привяжи первое упражнение к статистике матча
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button
|
||||||
</div>
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
{games.length > 0 && !games.some((g) => g.enabled) && (
|
setEditorOpen(true)
|
||||||
<div className="mt-6 rounded-xl bg-xp/10 border border-xp/30 p-4 text-sm flex items-start gap-2.5">
|
}}
|
||||||
<AlertTriangle size={16} className="text-xp shrink-0 mt-0.5" />
|
>
|
||||||
<div>
|
<Plus size={15} strokeWidth={2.5} /> Новый
|
||||||
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '}
|
</Button>
|
||||||
<strong className="text-text">Игры</strong>.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<ChallengeEditor
|
{noGamesActive && (
|
||||||
open={editorOpen}
|
<div className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-start gap-3">
|
||||||
challenge={editing}
|
<div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
|
||||||
onClose={() => setEditorOpen(false)}
|
<AlertTriangle size={18} strokeWidth={2.5} />
|
||||||
onSave={async (draft) => {
|
</div>
|
||||||
if (editing) await window.api.updateChallenge(editing.id, draft)
|
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
|
||||||
else await window.api.addChallenge(draft)
|
Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
|
||||||
setEditorOpen(false)
|
<span className="font-semibold text-text">«Игры»</span>.
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{challenges.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={`Все · ${challenges.length}`} />
|
||||||
|
<Card>
|
||||||
|
{challenges.map((c, i) => (
|
||||||
|
<Row
|
||||||
|
key={c.id}
|
||||||
|
last={i === challenges.length - 1}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(c)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
||||||
|
c.enabled
|
||||||
|
? 'bg-warning text-white'
|
||||||
|
: 'bg-text/15 text-text/45'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon name={c.icon} size={18} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] text-text/65 mt-1 inline-flex items-center gap-1.5 font-medium">
|
||||||
|
<Gamepad2 size={12} strokeWidth={2.4} />
|
||||||
|
{GAME_NAMES[c.gameId]} ·{' '}
|
||||||
|
<span className="font-mono-num font-semibold text-text">
|
||||||
|
{STAT_LABELS[c.stat]} × {c.multiplier}
|
||||||
|
</span>{' '}
|
||||||
|
→ {c.exerciseName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Switch
|
||||||
|
checked={c.enabled}
|
||||||
|
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-text/30" />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
|
||||||
|
Челленджей пока нет. Привяжи упражнение к статистике матча.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChallengeEditor
|
||||||
|
open={editorOpen}
|
||||||
|
challenge={editing}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={async (draft) => {
|
||||||
|
if (editing) await window.api.updateChallenge(editing.id, draft)
|
||||||
|
else await window.api.addChallenge(draft)
|
||||||
|
setEditorOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -228,10 +190,10 @@ function ChallengeEditor({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={challenge ? 'Редактировать челлендж' : 'Новый челлендж'}
|
title={challenge ? 'Редактировать' : 'Новый челлендж'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||||
@@ -245,8 +207,8 @@ function ChallengeEditor({
|
|||||||
<input
|
<input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
placeholder="Например, за смерти — приседания"
|
placeholder="За смерти — приседания"
|
||||||
className="input"
|
className="ios-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -257,7 +219,7 @@ function ChallengeEditor({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({ ...draft, gameId: e.target.value as GameId })
|
setDraft({ ...draft, gameId: e.target.value as GameId })
|
||||||
}
|
}
|
||||||
className="input"
|
className="ios-input"
|
||||||
>
|
>
|
||||||
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
|
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
@@ -267,14 +229,14 @@ function ChallengeEditor({
|
|||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Стат">
|
<Field label="Статистика">
|
||||||
<select
|
<select
|
||||||
value={draft.stat}
|
value={draft.stat}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({ ...draft, stat: e.target.value as GameStat })
|
setDraft({ ...draft, stat: e.target.value as GameStat })
|
||||||
}
|
}
|
||||||
className="input"
|
className="ios-input"
|
||||||
>
|
>
|
||||||
{GAME_STATS[draft.gameId].map((s) => (
|
{GAME_STATS[draft.gameId].map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
@@ -295,7 +257,7 @@ function ChallengeEditor({
|
|||||||
multiplier: Math.max(0.5, Number(e.target.value) || 1)
|
multiplier: Math.max(0.5, Number(e.target.value) || 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="input"
|
className="ios-input font-mono-num"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,74 +268,68 @@ function ChallengeEditor({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({ ...draft, exerciseName: e.target.value })
|
setDraft({ ...draft, exerciseName: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Например, приседания"
|
placeholder="Приседания"
|
||||||
className="input"
|
className="ios-input"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Иконка">
|
<Field label="Иконка">
|
||||||
<div className="grid grid-cols-9 gap-2 max-h-40 overflow-y-auto pr-1">
|
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||||
{ICON_CHOICES.map((name) => (
|
{ICON_CHOICES.map((name) => (
|
||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDraft({ ...draft, icon: name })}
|
onClick={() => setDraft({ ...draft, icon: name })}
|
||||||
className={[
|
className={[
|
||||||
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
|
'h-10 w-10 grid place-items-center rounded-xl transition-all active:scale-90',
|
||||||
draft.icon === name
|
draft.icon === name
|
||||||
? 'border-accent bg-accent/15 text-accent shadow-glow'
|
? 'bg-accent text-white'
|
||||||
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
|
: 'bg-surface text-text/65 hover:text-text'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={name} size={18} />
|
<Icon name={name} size={17} strokeWidth={2.2} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Formula preview */}
|
{/* Live preview */}
|
||||||
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden">
|
<div className="rounded-2xl bg-accent/8 p-4">
|
||||||
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
|
<div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
|
||||||
<div className="relative">
|
Превью · 5 событий
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold mb-2">
|
</div>
|
||||||
Preview · если 5 событий
|
<div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
|
||||||
</div>
|
<span>5 {STAT_LABELS[draft.stat]}</span>
|
||||||
<div className="flex items-center gap-2 text-sm font-mono-num">
|
<span className="text-text/40">×</span>
|
||||||
<span className="font-bold text-text">5</span>
|
<span>{draft.multiplier}</span>
|
||||||
<span className="text-muted">{STAT_LABELS[draft.stat]}</span>
|
<span className="text-text/40">=</span>
|
||||||
<span className="text-muted">×</span>
|
<span className="text-[32px] font-display font-semibold text-accent leading-none ml-1 tracking-tight">
|
||||||
<span className="font-bold text-accent">{draft.multiplier}</span>
|
{previewReps}
|
||||||
<span className="text-muted">=</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-gradient-brand leading-none ml-1">
|
<span className="text-text/55">
|
||||||
{previewReps}
|
{draft.exerciseName.toLowerCase() || 'повторов'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted ml-1">
|
|
||||||
{draft.exerciseName.toLowerCase() || 'упражнений'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.input {
|
.ios-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgb(var(--border));
|
border: 0;
|
||||||
background: rgb(var(--surface-elevated));
|
background: rgb(var(--surface-2));
|
||||||
color: rgb(var(--text));
|
color: rgb(var(--text));
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
transition: box-shadow .15s ease;
|
||||||
}
|
}
|
||||||
.input:focus {
|
.ios-input:focus {
|
||||||
border-color: rgb(var(--accent));
|
box-shadow: 0 0 0 2px rgb(var(--accent) / 0.45);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
|
|
||||||
}
|
|
||||||
select.input {
|
|
||||||
padding-right: 32px;
|
|
||||||
}
|
}
|
||||||
|
select.ios-input { padding-right: 32px; }
|
||||||
`}</style>
|
`}</style>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
@@ -388,7 +344,7 @@ function Field({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]">
|
<span className="block text-[12px] font-medium text-text/55 mb-1.5">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import {
|
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
|
||||||
Plus,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Timer,
|
|
||||||
Flame,
|
|
||||||
Activity,
|
|
||||||
Gamepad2,
|
|
||||||
Trophy
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { ExerciseCard } from '../components/ExerciseCard'
|
import { ExerciseCard } from '../components/ExerciseCard'
|
||||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise } from '@shared/types'
|
||||||
import { formatCountdown, formatInterval } from '../lib/format'
|
import { formatCountdown } from '../lib/format'
|
||||||
|
|
||||||
export default function Dashboard(): JSX.Element {
|
export default function Dashboard(): JSX.Element {
|
||||||
const state = useAppStore((s) => s.state)
|
const state = useAppStore((s) => s.state)
|
||||||
@@ -25,7 +16,6 @@ export default function Dashboard(): JSX.Element {
|
|||||||
|
|
||||||
const exercises = state?.exercises ?? []
|
const exercises = state?.exercises ?? []
|
||||||
const settings = state?.settings
|
const settings = state?.settings
|
||||||
const challenges = state?.challenges ?? []
|
|
||||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -33,32 +23,24 @@ export default function Dashboard(): JSX.Element {
|
|||||||
const next = enabled
|
const next = enabled
|
||||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||||
.sort((a, b) => a.ms - b.ms)[0]
|
.sort((a, b) => a.ms - b.ms)[0]
|
||||||
const totalReps = enabled.reduce((s, e) => s + e.reps, 0)
|
|
||||||
const avgInterval =
|
|
||||||
enabled.length > 0
|
|
||||||
? Math.round(
|
|
||||||
enabled.reduce((s, e) => s + e.intervalMinutes, 0) / enabled.length
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
return {
|
return {
|
||||||
total: exercises.length,
|
total: exercises.length,
|
||||||
active: enabled.length,
|
active: enabled.length,
|
||||||
nextMs: next?.ms ?? Infinity,
|
nextMs: next?.ms ?? Infinity,
|
||||||
totalReps,
|
totalReps: enabled.reduce((s, e) => s + e.reps, 0)
|
||||||
avgInterval
|
|
||||||
}
|
}
|
||||||
}, [exercises, ticks])
|
}, [exercises, ticks])
|
||||||
|
|
||||||
|
const paused = !settings?.globalEnabled
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(ex: Exercise): void {
|
function openEdit(ex: Exercise): void {
|
||||||
setEditing(ex)
|
setEditing(ex)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(draft: {
|
async function handleSave(draft: {
|
||||||
name: string
|
name: string
|
||||||
reps: number
|
reps: number
|
||||||
@@ -66,221 +48,199 @@ export default function Dashboard(): JSX.Element {
|
|||||||
intervalMinutes: number
|
intervalMinutes: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (editing) {
|
if (editing) await window.api.updateExercise(editing.id, draft)
|
||||||
await window.api.updateExercise(editing.id, draft)
|
else await window.api.addExercise(draft)
|
||||||
} else {
|
|
||||||
await window.api.addExercise(draft)
|
|
||||||
}
|
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string): Promise<void> {
|
|
||||||
await window.api.deleteExercise(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePause(): Promise<void> {
|
async function togglePause(): Promise<void> {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
const paused = !settings?.globalEnabled
|
const today = new Date().toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
|
<div className="h-full overflow-y-auto">
|
||||||
{/* Hero header */}
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
{/* Hero — iOS Large Title */}
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
<div className="min-w-0">
|
||||||
Тренировка дня
|
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
||||||
|
{today}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||||
|
Сегодня
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="tinted" onClick={togglePause}>
|
||||||
|
{!paused ? (
|
||||||
|
<>
|
||||||
|
<Pause size={14} strokeWidth={2.5} /> Пауза
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play size={14} strokeWidth={2.5} /> Старт
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
|
||||||
<span className="text-gradient-brand">Дашборд</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted mt-2">
|
|
||||||
{stats.active} активных из {stats.total} упражнений ·{' '}
|
|
||||||
<span className="text-text font-mono-num font-semibold">
|
|
||||||
{stats.totalReps}
|
|
||||||
</span>{' '}
|
|
||||||
повторов за цикл
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Button variant="secondary" onClick={togglePause}>
|
|
||||||
{!paused ? (
|
|
||||||
<>
|
|
||||||
<Pause size={16} /> Пауза
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play size={16} /> Возобновить
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={openCreate}>
|
|
||||||
<Plus size={16} /> Новое
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HUD stat strip */}
|
{/* Hero stat panel — Apple Fitness style */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
||||||
<HudStat
|
<HeroStat
|
||||||
icon={<Timer size={18} />}
|
tone="accent"
|
||||||
label="До следующего"
|
label="Активных"
|
||||||
value={
|
value={`${stats.active}`}
|
||||||
stats.nextMs === Infinity
|
subvalue={`из ${stats.total}`}
|
||||||
? '—'
|
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||||
: stats.nextMs <= 0
|
/>
|
||||||
? 'СЕЙЧАС'
|
<HeroStat
|
||||||
: formatCountdown(stats.nextMs)
|
tone="info"
|
||||||
}
|
label="До следующего"
|
||||||
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
|
value={
|
||||||
/>
|
stats.nextMs === Infinity
|
||||||
<HudStat
|
? '—'
|
||||||
icon={<Activity size={18} />}
|
: stats.nextMs <= 0
|
||||||
label="Активных"
|
? 'Сейчас'
|
||||||
value={`${stats.active}/${stats.total}`}
|
: formatCountdown(stats.nextMs)
|
||||||
/>
|
}
|
||||||
<HudStat
|
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
|
||||||
icon={<Flame size={18} />}
|
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||||
label="Avg интервал"
|
/>
|
||||||
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'}
|
<HeroStat
|
||||||
/>
|
tone={gamesEnabled ? 'success' : 'muted'}
|
||||||
<HudStat
|
label="Трекинг матчей"
|
||||||
icon={<Gamepad2 size={18} />}
|
value={gamesEnabled ? 'On' : 'Off'}
|
||||||
label="Трекинг матчей"
|
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
|
||||||
value={gamesEnabled ? 'LIVE' : 'OFF'}
|
icon={
|
||||||
accent={gamesEnabled}
|
<span
|
||||||
tone={gamesEnabled ? 'victory' : 'muted'}
|
className={[
|
||||||
|
'w-1.5 h-1.5 rounded-full',
|
||||||
|
gamesEnabled ? 'bg-white' : 'bg-text/30'
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paused banner */}
|
||||||
|
{paused && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
|
||||||
|
<Pause size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[16px] font-semibold leading-tight">
|
||||||
|
Напоминания на паузе
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] text-text/70 mt-1">
|
||||||
|
Возобнови, чтобы продолжить отсчёт
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="filled" size="sm" onClick={togglePause}>
|
||||||
|
<Play size={14} strokeWidth={2.5} /> Старт
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cards grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{exercises.map((ex) => (
|
||||||
|
<ExerciseCard
|
||||||
|
key={ex.id}
|
||||||
|
exercise={ex}
|
||||||
|
tick={ticks[ex.id]}
|
||||||
|
onEdit={() => openEdit(ex)}
|
||||||
|
onDelete={() => window.api.deleteExercise(ex.id)}
|
||||||
|
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
|
||||||
|
onMarkDone={() => window.api.markDone(ex.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exercises.length === 0 && (
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
|
||||||
|
<Plus size={24} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-[20px] font-semibold">
|
||||||
|
Программа пуста
|
||||||
|
</div>
|
||||||
|
<p className="text-[14px] text-text/55 mt-1">
|
||||||
|
Добавь первое упражнение, чтобы начать
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExerciseEditor
|
||||||
|
open={editorOpen}
|
||||||
|
exercise={editing}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Paused banner */}
|
|
||||||
{paused && (
|
|
||||||
<div className="mb-6 rounded-2xl border border-xp/30 bg-xp/10 px-5 py-3 flex items-center gap-3">
|
|
||||||
<div className="w-9 h-9 rounded-xl bg-xp/20 text-xp grid place-items-center">
|
|
||||||
<Pause size={16} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold text-sm">Тренировка на паузе</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5">
|
|
||||||
Напоминания не сработают, пока не возобновишь
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="victory" size="sm" onClick={togglePause}>
|
|
||||||
<Play size={14} /> GO
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Challenges shortcut */}
|
|
||||||
{challenges.length > 0 && (
|
|
||||||
<div className="mb-6 rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm px-5 py-4 flex items-center gap-4">
|
|
||||||
<div className="w-11 h-11 rounded-xl bg-accent-2/15 text-accent-2 grid place-items-center">
|
|
||||||
<Trophy size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-[10px] text-muted uppercase tracking-[0.18em] font-semibold">
|
|
||||||
Активные челленджи
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-display font-semibold mt-0.5">
|
|
||||||
{challenges.length} правил привязано к матчам
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block text-xs text-muted">
|
|
||||||
См. вкладку <span className="text-accent font-semibold">Челленджи</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Exercise grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
||||||
<AnimatePresence>
|
|
||||||
{exercises.map((ex) => (
|
|
||||||
<ExerciseCard
|
|
||||||
key={ex.id}
|
|
||||||
exercise={ex}
|
|
||||||
tick={ticks[ex.id]}
|
|
||||||
onEdit={() => openEdit(ex)}
|
|
||||||
onDelete={() => handleDelete(ex.id)}
|
|
||||||
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
|
|
||||||
onMarkDone={() => window.api.markDone(ex.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exercises.length === 0 && (
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-4">
|
|
||||||
<Plus size={28} />
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-xl font-semibold uppercase tracking-wider mb-1">
|
|
||||||
Старт пуст
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Добавь первое упражнение — и поехали
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ExerciseEditor
|
|
||||||
open={editorOpen}
|
|
||||||
exercise={editing}
|
|
||||||
onClose={() => setEditorOpen(false)}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HudStat({
|
function HeroStat({
|
||||||
icon,
|
tone,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
accent,
|
subvalue,
|
||||||
tone = 'accent'
|
icon
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
tone: 'accent' | 'info' | 'success' | 'muted'
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
accent?: boolean
|
subvalue?: string
|
||||||
tone?: 'accent' | 'victory' | 'muted'
|
icon?: React.ReactNode
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const toneClasses =
|
const toneBg =
|
||||||
tone === 'victory'
|
tone === 'accent'
|
||||||
? 'text-victory bg-victory/15'
|
? 'bg-accent'
|
||||||
: tone === 'muted'
|
: tone === 'info'
|
||||||
? 'text-muted bg-surface-elevated'
|
? 'bg-info'
|
||||||
: 'text-accent bg-accent/15'
|
: tone === 'success'
|
||||||
|
? 'bg-success'
|
||||||
|
: 'bg-text/40'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||||
className={[
|
<div className="flex items-center gap-2 mb-3">
|
||||||
'relative rounded-2xl border bg-surface/60 backdrop-blur-sm px-4 py-3 overflow-hidden',
|
|
||||||
accent ? 'border-accent/40 shadow-glow' : 'border-border/70'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{accent && (
|
|
||||||
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-accent/20 blur-2xl pointer-events-none" />
|
|
||||||
)}
|
|
||||||
<div className="relative flex items-center gap-3">
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
'w-7 h-7 rounded-lg grid place-items-center text-white',
|
||||||
toneClasses
|
toneBg
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="text-[14px] text-text/75 font-semibold">{label}</div>
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="font-display font-bold text-xl tracking-wide truncate">
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="font-display text-[28px] font-bold tracking-tight leading-none">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{subvalue && (
|
||||||
|
<div className="text-[13px] text-text/60 mt-2 font-medium">
|
||||||
|
{subvalue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Pencil, Trash2, ListChecks } from 'lucide-react'
|
import { Plus, ChevronRight } from 'lucide-react'
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { Icon } from '../lib/icon'
|
import { Icon } from '../lib/icon'
|
||||||
import { formatInterval } from '../lib/format'
|
import { formatInterval } from '../lib/format'
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise } from '@shared/types'
|
||||||
@@ -14,138 +14,137 @@ export default function Exercises(): JSX.Element {
|
|||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||||
|
|
||||||
const enabledCount = exercises.filter((e) => e.enabled).length
|
const enabled = exercises.filter((e) => e.enabled)
|
||||||
const totalReps = exercises
|
const disabled = exercises.filter((e) => !e.enabled)
|
||||||
.filter((e) => e.enabled)
|
|
||||||
.reduce((s, e) => s + e.reps, 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
<div>
|
||||||
Программа
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
|
Программа
|
||||||
|
</div>
|
||||||
|
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||||
|
Упражнения
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
<Button
|
||||||
<span className="text-gradient-brand">Упражнения</span>
|
onClick={() => {
|
||||||
</h1>
|
setEditing(null)
|
||||||
<p className="text-sm text-muted mt-2">
|
setEditorOpen(true)
|
||||||
<span className="text-text font-mono-num font-semibold">
|
}}
|
||||||
{enabledCount}
|
|
||||||
</span>{' '}
|
|
||||||
активных ·{' '}
|
|
||||||
<span className="text-text font-mono-num font-semibold">
|
|
||||||
{totalReps}
|
|
||||||
</span>{' '}
|
|
||||||
повторов за цикл
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="self-start sm:self-auto flex-shrink-0"
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(null)
|
|
||||||
setEditorOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={16} /> Добавить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
|
||||||
{exercises.map((ex, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={ex.id}
|
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: i * 0.02 }}
|
|
||||||
className={[
|
|
||||||
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
|
||||||
ex.enabled
|
|
||||||
? 'hover:bg-accent/[0.04]'
|
|
||||||
: 'opacity-70 hover:opacity-100',
|
|
||||||
i < exercises.length - 1 ? 'border-b border-border/40' : ''
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
<div
|
<Plus size={15} strokeWidth={2.5} /> Добавить
|
||||||
className={[
|
</Button>
|
||||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0 transition-colors',
|
</div>
|
||||||
ex.enabled
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'bg-surface-elevated text-muted'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<Icon name={ex.icon} size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-display font-semibold tracking-wide truncate text-base">
|
|
||||||
{ex.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-3">
|
|
||||||
<span>
|
|
||||||
<span className="font-mono-num font-bold text-text">
|
|
||||||
{ex.reps}
|
|
||||||
</span>{' '}
|
|
||||||
повторов
|
|
||||||
</span>
|
|
||||||
<span className="text-border">·</span>
|
|
||||||
<span>
|
|
||||||
каждые{' '}
|
|
||||||
<span className="font-mono-num text-text font-semibold">
|
|
||||||
{formatInterval(ex.intervalMinutes)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={ex.enabled}
|
|
||||||
onChange={(v) => window.api.toggleExercise(ex.id, v)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1 opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(ex)
|
|
||||||
setEditorOpen(true)
|
|
||||||
}}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text transition-colors"
|
|
||||||
aria-label="Редактировать"
|
|
||||||
>
|
|
||||||
<Pencil size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.api.deleteExercise(ex.id)}
|
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
|
||||||
aria-label="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
{exercises.length === 0 && (
|
|
||||||
<div className="px-5 py-16 text-center">
|
|
||||||
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
|
|
||||||
<ListChecks size={26} />
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-lg font-semibold uppercase tracking-wider">
|
|
||||||
Список пуст
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted mt-1">
|
|
||||||
Добавь первое упражнение через кнопку выше
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ExerciseEditor
|
{enabled.length > 0 && (
|
||||||
open={editorOpen}
|
<>
|
||||||
exercise={editing}
|
<SectionHeader title={`Активные · ${enabled.length}`} />
|
||||||
onClose={() => setEditorOpen(false)}
|
<Card className="mb-6">
|
||||||
onSave={async (draft) => {
|
{enabled.map((ex, i) => (
|
||||||
if (editing) await window.api.updateExercise(editing.id, draft)
|
<ExerciseRow
|
||||||
else await window.api.addExercise(draft)
|
key={ex.id}
|
||||||
setEditorOpen(false)
|
exercise={ex}
|
||||||
}}
|
last={i === enabled.length - 1}
|
||||||
/>
|
onEdit={() => {
|
||||||
|
setEditing(ex)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disabled.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={`Выключенные · ${disabled.length}`} />
|
||||||
|
<Card>
|
||||||
|
{disabled.map((ex, i) => (
|
||||||
|
<ExerciseRow
|
||||||
|
key={ex.id}
|
||||||
|
exercise={ex}
|
||||||
|
last={i === disabled.length - 1}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditing(ex)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercises.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
|
||||||
|
Программа пуста — добавь первое упражнение
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExerciseEditor
|
||||||
|
open={editorOpen}
|
||||||
|
exercise={editing}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={async (draft) => {
|
||||||
|
if (editing) await window.api.updateExercise(editing.id, draft)
|
||||||
|
else await window.api.addExercise(draft)
|
||||||
|
setEditorOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExerciseRow({
|
||||||
|
exercise,
|
||||||
|
last,
|
||||||
|
onEdit
|
||||||
|
}: {
|
||||||
|
exercise: Exercise
|
||||||
|
last: boolean
|
||||||
|
onEdit: () => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Row last={last}>
|
||||||
|
{/* Tinted icon plaque, iOS Settings style */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
||||||
|
exercise.enabled
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-text/15 text-text/45'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon name={exercise.icon} size={18} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="flex-1 min-w-0 text-left active:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||||
|
{exercise.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
||||||
|
{exercise.reps} раз · {formatInterval(exercise.intervalMinutes)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<Switch
|
||||||
|
checked={exercise.enabled}
|
||||||
|
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
|
||||||
|
aria-label="Включить/выключить"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="text-text/30 hover:text-text/60 transition-colors"
|
||||||
|
aria-label="Редактировать"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
Radio,
|
|
||||||
AlertTriangle
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
|
import { Card, SectionHeader } from '../components/ui/Card'
|
||||||
import type { GameId, GameStatus } from '@shared/types'
|
import type { GameId, GameStatus } from '@shared/types'
|
||||||
|
|
||||||
export default function GamesPage(): JSX.Element {
|
export default function GamesPage(): JSX.Element {
|
||||||
@@ -27,7 +27,6 @@ export default function GamesPage(): JSX.Element {
|
|||||||
async function refresh(): Promise<void> {
|
async function refresh(): Promise<void> {
|
||||||
setGames(await window.api.listGames())
|
setGames(await window.api.listGames())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function install(id: GameId): Promise<void> {
|
async function install(id: GameId): Promise<void> {
|
||||||
setBusy(id)
|
setBusy(id)
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +35,6 @@ export default function GamesPage(): JSX.Element {
|
|||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uninstall(id: GameId): Promise<void> {
|
async function uninstall(id: GameId): Promise<void> {
|
||||||
setBusy(id)
|
setBusy(id)
|
||||||
try {
|
try {
|
||||||
@@ -45,7 +43,6 @@ export default function GamesPage(): JSX.Element {
|
|||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(id: GameId, enabled: boolean): Promise<void> {
|
async function toggle(id: GameId, enabled: boolean): Promise<void> {
|
||||||
setBusy(id)
|
setBusy(id)
|
||||||
try {
|
try {
|
||||||
@@ -55,71 +52,72 @@ export default function GamesPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveCount = games.filter((g) => g.enabled && g.integrationActive).length
|
const liveCount = games.filter(
|
||||||
|
(g) => g.enabled && g.integrationActive
|
||||||
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-3xl">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
<div>
|
||||||
Трекинг матчей
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
</div>
|
Трекинг матчей
|
||||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
|
||||||
<span className="text-gradient-brand">Игры</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted mt-2">
|
|
||||||
Подключи игру — челленджи сработают сразу после матча
|
|
||||||
{liveCount > 0 && (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
<span className="text-victory font-mono-num font-bold">
|
|
||||||
{liveCount} LIVE
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="self-start sm:self-auto flex-shrink-0"
|
|
||||||
onClick={refresh}
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} /> Обновить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{games.map((g, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={g.id}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
>
|
|
||||||
<GameRow
|
|
||||||
game={g}
|
|
||||||
busy={busy === g.id}
|
|
||||||
onInstall={() => install(g.id)}
|
|
||||||
onUninstall={() => uninstall(g.id)}
|
|
||||||
onToggle={(v) => toggle(g.id, v)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
{games.length === 0 && (
|
|
||||||
<div className="px-5 py-12 text-center rounded-2xl border border-border/60 bg-surface/40">
|
|
||||||
<div className="font-display text-base text-muted uppercase tracking-wider">
|
|
||||||
Сканируем…
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||||
|
Игры
|
||||||
|
</h1>
|
||||||
|
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
|
||||||
|
Подключи игру — челленджи сработают сразу после матча
|
||||||
|
{liveCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span className="text-success font-mono-num font-bold">
|
||||||
|
{liveCount} live
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button variant="tinted" onClick={refresh}>
|
||||||
</div>
|
<RefreshCw size={14} strokeWidth={2.5} /> Обновить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DevPanel games={games} />
|
<SectionHeader title="Поддерживаемые" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{games.map((g, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={g.id}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
>
|
||||||
|
<GameCard
|
||||||
|
game={g}
|
||||||
|
busy={busy === g.id}
|
||||||
|
onInstall={() => install(g.id)}
|
||||||
|
onUninstall={() => uninstall(g.id)}
|
||||||
|
onToggle={(v) => toggle(g.id, v)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{games.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
|
||||||
|
Сканируем установленные игры…
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DevPanel games={games} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameRow({
|
function GameCard({
|
||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
onInstall,
|
onInstall,
|
||||||
@@ -139,77 +137,54 @@ function GameRow({
|
|||||||
game.enabled
|
game.enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||||
className={[
|
<div className="flex items-start justify-between gap-4">
|
||||||
'relative rounded-2xl border bg-surface/70 backdrop-blur-sm p-5 overflow-hidden transition-colors',
|
|
||||||
isLive
|
|
||||||
? 'border-victory/40 shadow-glow-victory'
|
|
||||||
: game.integrationActive
|
|
||||||
? 'border-accent/30'
|
|
||||||
: 'border-border/70 hover:border-accent/30'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{/* Glow corner */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'absolute -top-10 -right-10 w-40 h-40 rounded-full blur-3xl pointer-events-none',
|
|
||||||
isLive ? 'bg-victory/20' : game.integrationActive ? 'bg-accent/15' : ''
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-4 min-w-0 flex-1">
|
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||||
{/* Game icon plaque */}
|
<div
|
||||||
<div className="relative shrink-0">
|
className={[
|
||||||
<div
|
'w-12 h-12 rounded-2xl grid place-items-center shrink-0 text-white',
|
||||||
className={[
|
isLive
|
||||||
'absolute inset-0 rounded-2xl blur-md opacity-60',
|
? 'bg-success'
|
||||||
isLive
|
: game.integrationActive
|
||||||
? 'bg-gradient-victory'
|
? 'bg-accent'
|
||||||
: game.integrationActive
|
: 'bg-text/30'
|
||||||
? 'bg-gradient-brand'
|
].join(' ')}
|
||||||
: ''
|
>
|
||||||
].join(' ')}
|
<Gamepad2 size={22} strokeWidth={2.3} />
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'relative w-14 h-14 rounded-2xl grid place-items-center text-white',
|
|
||||||
isLive
|
|
||||||
? 'bg-gradient-victory shadow-glow-victory'
|
|
||||||
: game.integrationActive
|
|
||||||
? 'bg-gradient-brand shadow-glow'
|
|
||||||
: 'bg-surface-elevated text-muted border border-border'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<Gamepad2 size={26} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-display font-bold text-lg uppercase tracking-wide">
|
<h3 className="font-display text-[18px] font-bold tracking-tight">
|
||||||
{game.name}
|
{game.name}
|
||||||
</h3>
|
</h3>
|
||||||
<StatusBadge game={game} isLive={isLive} />
|
<StatusBadge game={game} isLive={isLive} />
|
||||||
</div>
|
</div>
|
||||||
{game.installPath && (
|
{game.installPath && (
|
||||||
<div className="text-[11px] text-muted mt-1.5 truncate font-mono opacity-70">
|
<div className="text-[13px] text-text/55 mt-1.5 truncate font-mono-num font-medium">
|
||||||
{game.installPath}
|
{game.installPath}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{game.installed && game.integrationActive && (
|
{game.installed && game.integrationActive && (
|
||||||
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
|
<Switch
|
||||||
|
checked={game.enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{game.integrationActive && game.launchOptionStatus === 'queued' && (
|
{game.integrationActive && game.launchOptionStatus === 'queued' && (
|
||||||
<div className="relative mt-4 rounded-xl bg-xp/10 border border-xp/30 p-3 text-sm flex items-start gap-2.5">
|
<div className="mt-4 rounded-2xl bg-warning/12 p-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
|
||||||
<Hourglass size={16} className="text-xp shrink-0 mt-0.5" />
|
<Hourglass
|
||||||
<div>
|
size={17}
|
||||||
|
className="text-warning shrink-0 mt-0.5"
|
||||||
|
strokeWidth={2.4}
|
||||||
|
/>
|
||||||
|
<div className="text-text/85">
|
||||||
Steam запущен. Параметр{' '}
|
Steam запущен. Параметр{' '}
|
||||||
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
|
<code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
|
||||||
{game.launchOption}
|
{game.launchOption}
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
пропишется автоматически при следующем закрытии Steam.
|
пропишется автоматически при следующем закрытии Steam.
|
||||||
@@ -218,29 +193,38 @@ function GameRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
|
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
|
||||||
<div className="relative mt-4 rounded-xl bg-defeat/10 border border-defeat/30 p-3 text-sm flex items-start gap-2.5">
|
<div className="mt-4 rounded-2xl bg-destructive/10 p-4 text-[14px] leading-relaxed flex items-start gap-2.5 font-medium">
|
||||||
<AlertTriangle size={16} className="text-defeat shrink-0 mt-0.5" />
|
<AlertTriangle
|
||||||
<div>
|
size={17}
|
||||||
|
className="text-destructive shrink-0 mt-0.5"
|
||||||
|
strokeWidth={2.4}
|
||||||
|
/>
|
||||||
|
<div className="text-text/85">
|
||||||
В Steam нет залогиненного аккаунта (нет папки{' '}
|
В Steam нет залогиненного аккаунта (нет папки{' '}
|
||||||
<code className="font-mono text-xs">userdata</code>). Запусти Steam
|
<code className="font-mono-num text-[13px] font-semibold">userdata</code>).
|
||||||
один раз — потом снова нажми «Установить интеграцию».
|
Запусти Steam один раз и нажми «Установить интеграцию».
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex items-center flex-wrap gap-2 mt-4">
|
<div className="flex items-center flex-wrap gap-2 mt-4">
|
||||||
{game.installed && !game.integrationActive && (
|
{game.installed && !game.integrationActive && (
|
||||||
<Button onClick={onInstall} disabled={busy}>
|
<Button onClick={onInstall} disabled={busy} size="sm">
|
||||||
<Download size={16} /> Установить интеграцию
|
<Download size={14} strokeWidth={2.5} /> Подключить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{game.integrationActive && (
|
{game.integrationActive && (
|
||||||
<Button variant="secondary" onClick={onUninstall} disabled={busy}>
|
<Button
|
||||||
<Trash2 size={16} /> Удалить интеграцию
|
variant="tinted"
|
||||||
|
onClick={onUninstall}
|
||||||
|
disabled={busy}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2.5} /> Отключить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!game.installed && (
|
{!game.installed && (
|
||||||
<div className="text-xs text-muted">
|
<div className="text-[14px] text-text/65 font-medium">
|
||||||
Установи игру в Steam и нажми «Обновить»
|
Установи игру в Steam и нажми «Обновить»
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -258,10 +242,10 @@ function StatusBadge({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-victory/15 text-victory font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-success/15 text-success font-semibold inline-flex items-center gap-1.5">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-70 animate-ping" />
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-victory" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
||||||
</span>
|
</span>
|
||||||
Live
|
Live
|
||||||
</span>
|
</span>
|
||||||
@@ -269,28 +253,28 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (game.integrationActive && game.launchOptionStatus === 'applied') {
|
if (game.integrationActive && game.launchOptionStatus === 'applied') {
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
|
||||||
<CheckCircle2 size={11} /> Ready
|
<CheckCircle2 size={11} strokeWidth={2.5} /> Готово
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (game.integrationActive && game.launchOptionStatus === 'queued') {
|
if (game.integrationActive && game.launchOptionStatus === 'queued') {
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-xp/15 text-xp font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
|
||||||
<Radio size={11} /> Queued
|
В очереди
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (game.installed) {
|
if (game.installed) {
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-text font-display font-bold uppercase tracking-widest">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
|
||||||
Installed
|
Установлена
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-display font-bold uppercase tracking-widest">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
|
||||||
Not found
|
Не найдена
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,10 +284,10 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
|||||||
const dota = games.find((g) => g.id === 'dota2')
|
const dota = games.find((g) => g.id === 'dota2')
|
||||||
if (!dota?.enabled) return null
|
if (!dota?.enabled) return null
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 pt-6 border-t border-border/40">
|
<div className="mt-10">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="text-[10px] uppercase tracking-[0.18em] text-muted hover:text-accent font-mono font-semibold transition-colors"
|
className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{open ? '▾' : '▸'} dev · симулировать конец матча
|
{open ? '▾' : '▸'} dev · симулировать конец матча
|
||||||
</button>
|
</button>
|
||||||
@@ -323,7 +307,7 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
|||||||
<button
|
<button
|
||||||
key={p.label}
|
key={p.label}
|
||||||
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
|
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
|
||||||
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-accent/15 hover:text-accent text-muted font-mono transition-colors border border-border/60"
|
className="text-[12px] px-3 py-1.5 rounded-full bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/70 font-medium transition-colors active:scale-95"
|
||||||
>
|
>
|
||||||
{p.label}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,164 +1,146 @@
|
|||||||
import { Bell, Monitor, Palette } from 'lucide-react'
|
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { UpdaterCard } from '../components/UpdaterCard'
|
import { UpdaterCard } from '../components/UpdaterCard'
|
||||||
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
|
import type {
|
||||||
|
NotificationMode,
|
||||||
|
Settings as SettingsType,
|
||||||
|
Theme
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
export default function SettingsPage(): JSX.Element {
|
export default function SettingsPage(): JSX.Element {
|
||||||
const settings = useAppStore((s) => s.state?.settings)
|
const settings = useAppStore((s) => s.state?.settings)
|
||||||
if (!settings)
|
if (!settings)
|
||||||
return (
|
return <div className="p-8 text-text/45">Загрузка…</div>
|
||||||
<div className="p-8 text-muted font-display uppercase tracking-wider">
|
|
||||||
Загрузка…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const patch = (p: Partial<SettingsType>): void => {
|
const patch = (p: Partial<SettingsType>): void => {
|
||||||
window.api.updateSettings(p)
|
window.api.updateSettings(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full max-w-2xl">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="mb-8">
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
<div className="mb-8">
|
||||||
Конфигурация
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
|
Конфигурация
|
||||||
|
</div>
|
||||||
|
<h1 className="font-serif text-[32px] sm:text-[36px] leading-[1.05] tracking-tight mt-1 font-medium">
|
||||||
|
Настройки
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
|
|
||||||
<span className="text-gradient-brand">Настройки</span>
|
{/* Reminders */}
|
||||||
</h1>
|
<SectionHeader title="Напоминания" />
|
||||||
<p className="text-sm text-muted mt-2">
|
<Card className="mb-6">
|
||||||
Тонкая настройка поведения приложения
|
<SelectRow
|
||||||
</p>
|
label="Режим уведомления"
|
||||||
|
hint="Как должно выглядеть напоминание"
|
||||||
|
value={settings.notificationMode}
|
||||||
|
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
|
||||||
|
options={[
|
||||||
|
{ value: 'modal', label: 'Окно поверх всех' },
|
||||||
|
{ value: 'toast', label: 'Системное уведомление' },
|
||||||
|
{ value: 'both', label: 'Окно и уведомление' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Звук уведомления"
|
||||||
|
hint="Короткий сигнал при срабатывании"
|
||||||
|
checked={settings.soundEnabled}
|
||||||
|
onChange={(v) => patch({ soundEnabled: v })}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
label="«Отложить» на"
|
||||||
|
hint="Сколько минут добавлять при отложении"
|
||||||
|
value={String(settings.snoozeMinutes)}
|
||||||
|
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '1 минута' },
|
||||||
|
{ value: '5', label: '5 минут' },
|
||||||
|
{ value: '10', label: '10 минут' },
|
||||||
|
{ value: '15', label: '15 минут' },
|
||||||
|
{ value: '30', label: '30 минут' }
|
||||||
|
]}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Window */}
|
||||||
|
<SectionHeader title="Окно и трей" />
|
||||||
|
<Card className="mb-6">
|
||||||
|
<ToggleRow
|
||||||
|
label="Сворачивать в трей"
|
||||||
|
hint="При закрытии остаётся работать в фоне"
|
||||||
|
checked={settings.minimizeToTray}
|
||||||
|
onChange={(v) => patch({ minimizeToTray: v })}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Запускать с Windows"
|
||||||
|
hint="Открывать при входе в систему"
|
||||||
|
checked={settings.startWithWindows}
|
||||||
|
onChange={(v) => patch({ startWithWindows: v })}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Запускать свёрнутым"
|
||||||
|
hint="При автозапуске открывать сразу в трее"
|
||||||
|
checked={settings.startMinimized}
|
||||||
|
onChange={(v) => patch({ startMinimized: v })}
|
||||||
|
disabled={!settings.startWithWindows}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<SectionHeader title="Внешний вид" />
|
||||||
|
<Card className="mb-6">
|
||||||
|
<SelectRow
|
||||||
|
label="Тема"
|
||||||
|
hint="Светлая / тёмная / как в системе"
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(v) => patch({ theme: v as Theme })}
|
||||||
|
options={[
|
||||||
|
{ value: 'system', label: 'Как в системе' },
|
||||||
|
{ value: 'light', label: 'Светлая' },
|
||||||
|
{ value: 'dark', label: 'Тёмная' }
|
||||||
|
]}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionHeader title="Обновления" />
|
||||||
|
<UpdaterCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section title="Напоминания" icon={<Bell size={14} />}>
|
|
||||||
<SelectRow
|
|
||||||
label="Режим уведомления"
|
|
||||||
hint="Как должно выглядеть напоминание"
|
|
||||||
value={settings.notificationMode}
|
|
||||||
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
|
|
||||||
options={[
|
|
||||||
{ value: 'modal', label: 'Большое окно поверх всех' },
|
|
||||||
{ value: 'toast', label: 'Тихое системное уведомление' },
|
|
||||||
{ value: 'both', label: 'И окно, и уведомление' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label="Звук уведомления"
|
|
||||||
hint="Короткий сигнал при срабатывании"
|
|
||||||
checked={settings.soundEnabled}
|
|
||||||
onChange={(v) => patch({ soundEnabled: v })}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
label="Интервал «Отложить»"
|
|
||||||
hint="На сколько минут откладывать при нажатии «Отложить»"
|
|
||||||
value={String(settings.snoozeMinutes)}
|
|
||||||
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
|
||||||
options={[
|
|
||||||
{ value: '1', label: '1 минута' },
|
|
||||||
{ value: '5', label: '5 минут' },
|
|
||||||
{ value: '10', label: '10 минут' },
|
|
||||||
{ value: '15', label: '15 минут' },
|
|
||||||
{ value: '30', label: '30 минут' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Окно и трей" icon={<Monitor size={14} />}>
|
|
||||||
<ToggleRow
|
|
||||||
label="Сворачивать в трей"
|
|
||||||
hint="При закрытии окна приложение остаётся работать в системном трее"
|
|
||||||
checked={settings.minimizeToTray}
|
|
||||||
onChange={(v) => patch({ minimizeToTray: v })}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label="Запускать с Windows"
|
|
||||||
hint="Открывать приложение автоматически при входе в систему"
|
|
||||||
checked={settings.startWithWindows}
|
|
||||||
onChange={(v) => patch({ startWithWindows: v })}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label="Запускать свёрнутым"
|
|
||||||
hint="При автозапуске открывать сразу в трее"
|
|
||||||
checked={settings.startMinimized}
|
|
||||||
onChange={(v) => patch({ startMinimized: v })}
|
|
||||||
disabled={!settings.startWithWindows}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Внешний вид" icon={<Palette size={14} />}>
|
|
||||||
<SelectRow
|
|
||||||
label="Тема"
|
|
||||||
hint="Тёмная подходит к спортивной эстетике приложения"
|
|
||||||
value={settings.theme}
|
|
||||||
onChange={(v) => patch({ theme: v as Theme })}
|
|
||||||
options={[
|
|
||||||
{ value: 'system', label: 'Как в системе' },
|
|
||||||
{ value: 'light', label: 'Светлая' },
|
|
||||||
{ value: 'dark', label: 'Тёмная' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<UpdaterCard />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
children: React.ReactNode
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<section className="mb-7">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className="w-7 h-7 rounded-lg bg-accent/15 text-accent grid place-items-center">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-[10px] uppercase tracking-[0.22em] text-muted font-display font-bold">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
disabled
|
disabled,
|
||||||
|
last = false
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
hint?: string
|
hint?: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
last?: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div
|
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||||
className={[
|
<div className="flex-1 min-w-0">
|
||||||
'flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors',
|
<div className="text-[15px] font-semibold leading-tight">{label}</div>
|
||||||
disabled ? 'opacity-50' : 'hover:bg-accent/[0.03]'
|
{hint && (
|
||||||
].join(' ')}
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
>
|
{hint}
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<div className="font-display font-semibold text-sm tracking-wide">
|
)}
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={checked} onChange={onChange} disabled={disabled} />
|
<Switch checked={checked} onChange={onChange} disabled={disabled} />
|
||||||
</div>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,26 +149,30 @@ function SelectRow({
|
|||||||
hint,
|
hint,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options
|
options,
|
||||||
|
last = false
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
hint?: string
|
hint?: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
options: { value: string; label: string }[]
|
options: { value: string; label: string }[]
|
||||||
|
last?: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors hover:bg-accent/[0.03]">
|
<Row last={last}>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-display font-semibold text-sm tracking-wide">
|
<div className="text-[15px] font-semibold leading-tight">{label}</div>
|
||||||
{label}
|
{hint && (
|
||||||
</div>
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
{hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 transition-all"
|
className="h-9 pl-3 pr-8 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0"
|
||||||
>
|
>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
@@ -194,6 +180,6 @@ function SelectRow({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,60 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ===== Design tokens — Apple HIG with Manrope/Fraunces ===== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Sport palette — Strava-inspired energy orange + intense rose pair */
|
/* Brand & semantic colors (iOS system palette) */
|
||||||
--accent: 249 115 22; /* orange-500 — primary energy / "GO" */
|
--accent: 255 107 53; /* Apple Fitness Move orange */
|
||||||
--accent-soft: 249 115 22;
|
--accent-2: 255 45 85; /* systemPink */
|
||||||
--accent-2: 244 63 94; /* rose-500 — gradient pair, intensity */
|
--success: 52 199 89; /* systemGreen */
|
||||||
--victory: 132 204 22; /* lime-500 — done / personal best */
|
--warning: 255 159 10; /* systemOrange dark */
|
||||||
--defeat: 220 38 38; /* red-600 — danger */
|
--destructive: 255 59 48; /* systemRed */
|
||||||
--xp: 245 158 11; /* amber-500 — streak / XP */
|
--info: 0 122 255; /* systemBlue */
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme — clean athletic paper feel */
|
/* Light — polished iOS groupedBackground with warm undertone */
|
||||||
:root {
|
:root {
|
||||||
--bg: 250 250 251;
|
--bg: 245 245 249; /* slightly warmer than 242,242,247 */
|
||||||
--bg-deep: 240 240 244;
|
|
||||||
--surface: 255 255 255;
|
--surface: 255 255 255;
|
||||||
--surface-elevated: 248 248 250;
|
--surface-2: 240 240 245; /* subtle separation for inputs/sections */
|
||||||
--border: 226 228 234;
|
--text: 17 17 19; /* not pure black — softer */
|
||||||
--text: 17 18 24;
|
--text-secondary: 60 60 67;
|
||||||
--muted: 102 105 120;
|
--text-tertiary: 60 60 67;
|
||||||
|
--hairline: 60 60 67;
|
||||||
|
--vibrancy: 255 255 255;
|
||||||
|
|
||||||
|
--accent-soft: 255 107 53;
|
||||||
|
--bg-deep: 232 232 238;
|
||||||
|
--surface-elevated: 255 255 255;
|
||||||
|
--border: 60 60 67;
|
||||||
|
--muted: 60 60 67;
|
||||||
|
--victory: 52 199 89;
|
||||||
|
--defeat: 255 59 48;
|
||||||
|
--xp: 255 159 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme — warm graphite (not cool navy) */
|
/* Dark — true black with grey elevation */
|
||||||
.dark {
|
.dark {
|
||||||
--bg: 13 14 18;
|
--bg: 0 0 0;
|
||||||
--bg-deep: 7 8 11;
|
--surface: 28 28 30;
|
||||||
--surface: 22 24 30;
|
--surface-2: 44 44 46;
|
||||||
--surface-elevated: 30 32 40;
|
--text: 255 255 255;
|
||||||
--border: 50 53 64;
|
--text-secondary: 235 235 245;
|
||||||
--text: 236 238 244;
|
--text-tertiary: 235 235 245;
|
||||||
--muted: 148 152 165;
|
--hairline: 84 84 88;
|
||||||
|
--vibrancy: 28 28 30;
|
||||||
|
|
||||||
|
--bg-deep: 0 0 0;
|
||||||
|
--surface-elevated: 44 44 46;
|
||||||
|
--border: 84 84 88;
|
||||||
|
--muted: 235 235 245;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Base ===== */
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -45,48 +66,49 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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));
|
color: rgb(var(--text));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: rgb(var(--bg));
|
font-size: 14px;
|
||||||
background-image:
|
line-height: 1.45;
|
||||||
radial-gradient(
|
letter-spacing: -0.005em;
|
||||||
1200px 600px at 85% -10%,
|
|
||||||
rgb(var(--accent) / 0.12),
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
900px 500px at -10% 110%,
|
|
||||||
rgb(var(--accent-2) / 0.1),
|
|
||||||
transparent 60%
|
|
||||||
);
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
/* Display — same Manrope but slightly tighter for headings */
|
||||||
background-image:
|
|
||||||
radial-gradient(
|
|
||||||
1200px 600px at 85% -10%,
|
|
||||||
rgb(var(--accent) / 0.18),
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
900px 500px at -10% 110%,
|
|
||||||
rgb(var(--accent-2) / 0.14),
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
linear-gradient(180deg, rgb(var(--bg-deep)) 0%, rgb(var(--bg)) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Display font for big numbers / sport headers */
|
|
||||||
.font-display {
|
.font-display {
|
||||||
font-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif;
|
font-family:
|
||||||
letter-spacing: 0.02em;
|
'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-mono-num {
|
||||||
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code',
|
||||||
|
Menlo, monospace;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'ss02', 'ss19', 'zero';
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom titlebar drag region */
|
/* Custom titlebar drag region */
|
||||||
@@ -99,160 +121,84 @@ body {
|
|||||||
app-region: no-drag;
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iOS 0.5px-style hairlines */
|
||||||
|
.hairline-b {
|
||||||
|
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.18);
|
||||||
|
}
|
||||||
|
.hairline-t {
|
||||||
|
box-shadow: inset 0 0.5px 0 rgb(var(--hairline) / 0.18);
|
||||||
|
}
|
||||||
|
.dark .hairline-b,
|
||||||
|
.dark .hairline-t {
|
||||||
|
box-shadow: inset 0 -0.5px 0 rgb(var(--hairline) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* macOS vibrancy */
|
||||||
|
.vibrancy {
|
||||||
|
background-color: rgb(var(--vibrancy) / 0.72);
|
||||||
|
backdrop-filter: saturate(180%) blur(30px);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft iOS card shadow with subtle warmth */
|
||||||
|
.shadow-card {
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 rgb(0 0 0 / 0.03),
|
||||||
|
0 1px 2px rgb(15 23 42 / 0.04),
|
||||||
|
0 6px 14px -4px rgb(15 23 42 / 0.05);
|
||||||
|
}
|
||||||
|
.dark .shadow-card {
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 rgb(255 255 255 / 0.04) inset,
|
||||||
|
0 1px 2px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgb(var(--border));
|
background: rgb(var(--text-tertiary) / 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgb(var(--accent) / 0.4);
|
background: rgb(var(--text-secondary) / 0.45);
|
||||||
background-clip: padding-box;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection */
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgb(var(--accent) / 0.4);
|
background: rgb(var(--accent) / 0.25);
|
||||||
color: rgb(var(--text));
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--accent) / 0.55);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reminder-window root: neon HUD frame */
|
|
||||||
.reminder-shell {
|
.reminder-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid rgb(var(--accent) / 0.5);
|
border: 0.5px solid rgb(var(--hairline) / 0.25);
|
||||||
border-radius: 20px;
|
border-radius: 22px;
|
||||||
background:
|
background: rgb(var(--surface));
|
||||||
radial-gradient(
|
|
||||||
circle at 50% -20%,
|
|
||||||
rgb(var(--accent) / 0.22),
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
linear-gradient(180deg, rgb(var(--surface-elevated)) 0%, rgb(var(--surface)) 100%);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgb(var(--accent) / 0.15),
|
0 1px 2px rgb(0 0 0 / 0.06),
|
||||||
0 20px 80px -20px rgb(var(--accent) / 0.45),
|
0 20px 50px -16px rgb(0 0 0 / 0.4);
|
||||||
0 24px 60px -20px rgb(0 0 0 / 0.6);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Soft scanline texture for HUD surfaces */
|
.text-secondary {
|
||||||
.hud-scanlines {
|
color: rgb(var(--text-secondary) / 0.6);
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgb(var(--text) / 0.03) 0px,
|
|
||||||
rgb(var(--text) / 0.03) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 3px
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
.text-tertiary {
|
||||||
/* Gradient text and gradient brand */
|
color: rgb(var(--text-tertiary) / 0.3);
|
||||||
.text-gradient-brand {
|
|
||||||
background-image: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgb(var(--accent)) 0%,
|
|
||||||
rgb(var(--accent-2)) 100%
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
.bg-gradient-brand {
|
.dark .text-secondary {
|
||||||
background-image: linear-gradient(
|
color: rgb(var(--text-secondary) / 0.6);
|
||||||
135deg,
|
|
||||||
rgb(var(--accent)) 0%,
|
|
||||||
rgb(var(--accent-2)) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
.bg-gradient-victory {
|
.dark .text-tertiary {
|
||||||
background-image: linear-gradient(
|
color: rgb(var(--text-tertiary) / 0.3);
|
||||||
135deg,
|
|
||||||
rgb(var(--victory)) 0%,
|
|
||||||
rgb(var(--accent)) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neon border (animated gradient stroke for "due" / "active" cards) */
|
|
||||||
.neon-border {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
.neon-border::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
padding: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgb(var(--accent)) 0%,
|
|
||||||
rgb(var(--accent-2)) 60%,
|
|
||||||
rgb(var(--accent)) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 200%;
|
|
||||||
animation: neon-shift 6s linear infinite;
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
mask-composite: exclude;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HUD pulse — soft outer glow for "due" cards */
|
|
||||||
.hud-pulse {
|
|
||||||
animation: hud-pulse 2.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes neon-shift {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes hud-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 0 rgb(var(--accent) / 0.45),
|
|
||||||
0 12px 30px -10px rgb(var(--accent) / 0.4);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 6px rgb(var(--accent) / 0),
|
|
||||||
0 18px 40px -10px rgb(var(--accent) / 0.55);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle dot-grid texture (sidebar / hero strip) */
|
|
||||||
.dot-grid {
|
|
||||||
background-image: radial-gradient(
|
|
||||||
rgb(var(--text) / 0.07) 1px,
|
|
||||||
transparent 1px
|
|
||||||
);
|
|
||||||
background-size: 14px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cooldown ring SVG helpers */
|
|
||||||
.cooldown-track {
|
|
||||||
stroke: rgb(var(--border));
|
|
||||||
}
|
|
||||||
.cooldown-fill {
|
|
||||||
stroke: url(#cooldownGrad);
|
|
||||||
stroke-linecap: round;
|
|
||||||
filter: drop-shadow(0 0 6px rgb(var(--accent) / 0.6));
|
|
||||||
transition: stroke-dashoffset 0.5s linear;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
startWithWindows: false,
|
startWithWindows: false,
|
||||||
minimizeToTray: true,
|
minimizeToTray: true,
|
||||||
startMinimized: false,
|
startMinimized: false,
|
||||||
theme: 'system',
|
theme: 'light',
|
||||||
snoozeMinutes: 5
|
snoozeMinutes: 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,60 +5,77 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// iOS semantic palette
|
||||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||||
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
||||||
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
|
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
|
||||||
victory: 'rgb(var(--victory) / <alpha-value>)',
|
success: 'rgb(var(--success) / <alpha-value>)',
|
||||||
defeat: 'rgb(var(--defeat) / <alpha-value>)',
|
warning: 'rgb(var(--warning) / <alpha-value>)',
|
||||||
xp: 'rgb(var(--xp) / <alpha-value>)',
|
destructive: 'rgb(var(--destructive) / <alpha-value>)',
|
||||||
|
info: 'rgb(var(--info) / <alpha-value>)',
|
||||||
|
|
||||||
|
// Surfaces
|
||||||
bg: 'rgb(var(--bg) / <alpha-value>)',
|
bg: 'rgb(var(--bg) / <alpha-value>)',
|
||||||
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
|
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
|
||||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||||
|
'surface-2': 'rgb(var(--surface-2) / <alpha-value>)',
|
||||||
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
|
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
|
||||||
border: 'rgb(var(--border) / <alpha-value>)',
|
|
||||||
|
// Text & lines
|
||||||
text: 'rgb(var(--text) / <alpha-value>)',
|
text: 'rgb(var(--text) / <alpha-value>)',
|
||||||
muted: 'rgb(var(--muted) / <alpha-value>)'
|
muted: 'rgb(var(--muted) / <alpha-value>)',
|
||||||
|
hairline: 'rgb(var(--hairline) / <alpha-value>)',
|
||||||
|
border: 'rgb(var(--border) / <alpha-value>)',
|
||||||
|
|
||||||
|
// Legacy aliases (so unchanged pages still compile)
|
||||||
|
victory: 'rgb(var(--victory) / <alpha-value>)',
|
||||||
|
defeat: 'rgb(var(--defeat) / <alpha-value>)',
|
||||||
|
xp: 'rgb(var(--xp) / <alpha-value>)'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'Segoe UI Variable', 'Segoe UI', 'system-ui', 'sans-serif'],
|
sans: [
|
||||||
display: ['Rajdhani', 'Inter', 'Segoe UI Variable', 'sans-serif'],
|
'Manrope',
|
||||||
mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'Menlo', 'monospace']
|
'-apple-system',
|
||||||
|
'SF Pro Text',
|
||||||
|
'Segoe UI Variable Text',
|
||||||
|
'Segoe UI',
|
||||||
|
'system-ui',
|
||||||
|
'sans-serif'
|
||||||
|
],
|
||||||
|
display: [
|
||||||
|
'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: {
|
boxShadow: {
|
||||||
soft: '0 8px 30px -12px rgb(0 0 0 / 0.35)',
|
ios: '0 0.5px 0 rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04)',
|
||||||
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.55)',
|
sheet:
|
||||||
'glow-lg':
|
'0 1px 2px rgb(0 0 0 / 0.06), 0 20px 50px -16px rgb(0 0 0 / 0.4)'
|
||||||
'0 0 0 1px rgb(var(--accent) / 0.45), 0 18px 48px -12px rgb(var(--accent) / 0.7)',
|
|
||||||
'glow-victory':
|
|
||||||
'0 0 0 1px rgb(var(--victory) / 0.45), 0 12px 32px -10px rgb(var(--victory) / 0.55)',
|
|
||||||
hud: '0 1px 0 rgb(var(--text) / 0.04) inset, 0 0 0 1px rgb(var(--border) / 0.8), 0 18px 40px -20px rgb(0 0 0 / 0.4)'
|
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
'gradient-brand':
|
|
||||||
'linear-gradient(135deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 100%)',
|
|
||||||
'gradient-victory':
|
|
||||||
'linear-gradient(135deg, rgb(var(--victory)) 0%, rgb(var(--accent)) 100%)',
|
|
||||||
'gradient-defeat':
|
|
||||||
'linear-gradient(135deg, rgb(var(--defeat)) 0%, rgb(var(--accent-2)) 100%)'
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'pulse-ring': 'pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
||||||
shimmer: 'shimmer 2.5s linear infinite',
|
|
||||||
'neon-shift': 'neon-shift 6s linear infinite'
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
'pulse-ring': {
|
|
||||||
'0%, 100%': { transform: 'scale(1)', opacity: '0.7' },
|
|
||||||
'50%': { transform: 'scale(1.1)', opacity: '0.25' }
|
|
||||||
},
|
|
||||||
shimmer: {
|
|
||||||
'0%': { backgroundPosition: '-200% 0' },
|
|
||||||
'100%': { backgroundPosition: '200% 0' }
|
|
||||||
},
|
|
||||||
'neon-shift': {
|
|
||||||
'0%': { backgroundPosition: '0% 50%' },
|
|
||||||
'100%': { backgroundPosition: '200% 50%' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user