redesign(ui): phase 1 — esports HUD design system + core surfaces
Сменили визуальную ДНК на dark-first гейминг-эстетику: cyan→violet
неоновая палитра, спортивный display-шрифт Rajdhani, моноширинный
HUD для всех счётчиков, градиентные CTA с glow.
Дизайн-система (globals.css + tailwind.config.js):
- Brand-токены accent (cyan), accent-2 (violet), victory (lime),
defeat (rose), xp (amber); --bg-deep слой
- Утилиты .neon-border (анимированный обводный градиент),
.hud-pulse, .hud-scanlines, .dot-grid, .text-gradient-brand,
.bg-gradient-brand/-victory/-defeat
- Радиальные градиенты на body (cyan/violet glow по углам)
- Шрифты Rajdhani (display) и JetBrains Mono подключены через CDN
Компоненты:
- Sidebar: gradient-логотип, активный пункт с вертикальной gradient-
полосой и shadow-glow, статус-чип GSI tracking
- Titlebar: glass + scanlines, моноширинный лейбл, defeat hover на X
- Button: primary = bg-gradient-brand + shadow-glow; новый variant
victory (lime-gradient)
- ExerciseCard: SVG cooldown-ring как у способностей в MOBA,
градиентный stroke с drop-shadow, .neon-border на due, hover lift
- Dashboard: hero с gradient-text заголовком, HUD-полоса из 4 stat-
карточек (Cooldown / Active / Avg / Game tracking LIVE/OFF)
- ReminderApp: вращающийся conic-gradient вокруг иконки, HUD-блок
reps в моноширинном шрифте, кнопки с подписями хоткеев,
Match summary с heroGradient по результату (victory/defeat/brand)
ThemeProvider больше не перезаписывает --accent системным —
у laude теперь стабильная brand-идентичность.
Бонус: хоткеи на reminder-окне (Enter=done, Space=snooze, Esc=skip).
Откат: git revert HEAD или git reset --hard 688a86b
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
<title>Exercise Reminder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Clock, X, Trophy, Skull, Gamepad2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
X,
|
||||
Trophy,
|
||||
Skull,
|
||||
Gamepad2,
|
||||
Flame,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
|
||||
import { Icon } from './lib/icon'
|
||||
import { formatInterval } from './lib/format'
|
||||
@@ -37,6 +46,26 @@ export default function ReminderApp(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts on reminder window
|
||||
useEffect(() => {
|
||||
if (mode.kind !== 'exercise') return
|
||||
const ex = mode.exercise
|
||||
const snoozeMin = settings?.snoozeMinutes ?? 5
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') {
|
||||
window.api.markDone(ex.id).then(close)
|
||||
} else if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
window.api.snooze(ex.id, snoozeMin).then(close)
|
||||
} else if (e.key === 'Escape') {
|
||||
window.api.skip(ex.id).then(close)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, settings?.snoozeMinutes])
|
||||
|
||||
function close(): void {
|
||||
setMode({ kind: 'idle' })
|
||||
window.api.reminderClose()
|
||||
@@ -91,11 +120,14 @@ function ExerciseReminder({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
<div className="titlebar-drag h-8 px-3 flex items-center justify-end">
|
||||
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-accent font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
||||
<Flame size={11} /> Cooldown ready
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={13} />
|
||||
@@ -103,44 +135,85 @@ function ExerciseReminder({
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
|
||||
className="relative mb-5"
|
||||
initial={{ scale: 0.6, opacity: 0, rotate: -8 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 16 }}
|
||||
className="relative mb-6"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-accent/30 animate-pulse-ring" />
|
||||
<div className="relative w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-glow">
|
||||
<Icon name={exercise.icon} size={44} />
|
||||
{/* Outer rotating ring */}
|
||||
<motion.div
|
||||
className="absolute -inset-3 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 50%, rgb(var(--accent)) 100%)'
|
||||
}}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
<div className="absolute -inset-3 rounded-full bg-surface m-[3px]" />
|
||||
<div className="absolute inset-0 rounded-full bg-accent/40 blur-2xl animate-pulse-ring" />
|
||||
<div className="relative w-28 h-28 rounded-full bg-gradient-brand text-white grid place-items-center shadow-glow-lg">
|
||||
<Icon name={exercise.icon} size={48} />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted">Время размяться</div>
|
||||
<h1 className="text-3xl font-bold mt-2 mb-1">{exercise.name}</h1>
|
||||
<div className="text-5xl font-extrabold text-accent tabular-nums mt-1">
|
||||
{exercise.reps}
|
||||
<span className="text-base font-medium text-muted ml-2">раз</span>
|
||||
|
||||
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold">
|
||||
Время размяться
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-2">
|
||||
Следующее напоминание через {formatInterval(exercise.intervalMinutes)}
|
||||
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide">
|
||||
{exercise.name}
|
||||
</h1>
|
||||
|
||||
{/* HUD reps counter */}
|
||||
<div className="inline-flex items-baseline gap-2 px-5 py-2 rounded-2xl border border-accent/30 bg-accent/10 shadow-glow">
|
||||
<Zap size={16} className="text-xp" />
|
||||
<span className="font-mono-num font-bold text-5xl text-gradient-brand leading-none">
|
||||
{exercise.reps}
|
||||
</span>
|
||||
<span className="text-xs font-display font-semibold text-muted uppercase tracking-widest">
|
||||
REPS
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
|
||||
<Clock size={10} />
|
||||
Next drop через {formatInterval(exercise.intervalMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={skip}
|
||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-muted hover:text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
||||
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"
|
||||
>
|
||||
<X size={14} /> Пропустить
|
||||
<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}
|
||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
||||
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"
|
||||
>
|
||||
<Clock size={14} /> Отложить {snoozeMinutes}м
|
||||
<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
|
||||
onClick={done}
|
||||
className="h-12 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center justify-center gap-1.5 shadow-glow"
|
||||
title="Enter"
|
||||
className="group h-12 rounded-xl bg-gradient-victory text-white text-sm font-bold uppercase tracking-wide inline-flex flex-col items-center justify-center gap-0.5 shadow-glow-victory hover:brightness-110 transition-all"
|
||||
>
|
||||
<Check size={16} /> Сделал
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check size={16} /> Сделал
|
||||
</span>
|
||||
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,16 +236,24 @@ function MatchSummaryView({
|
||||
const remainingReps = summary.results
|
||||
.filter((r) => !done.has(r.challengeId))
|
||||
.reduce((s, r) => s + r.reps, 0)
|
||||
const won = summary.won === true
|
||||
const lost = summary.won === false
|
||||
|
||||
const heroGradient = won
|
||||
? 'bg-gradient-victory'
|
||||
: lost
|
||||
? 'bg-gradient-defeat'
|
||||
: 'bg-gradient-brand'
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted inline-flex items-center gap-1.5 px-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-muted font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
||||
<Gamepad2 size={12} /> {summary.gameName}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={13} />
|
||||
@@ -181,38 +262,48 @@ function MatchSummaryView({
|
||||
|
||||
<div className="px-6 pt-2 pb-4 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
|
||||
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/15 text-accent mb-3"
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3"
|
||||
>
|
||||
{summary.won === true ? (
|
||||
<Trophy size={28} />
|
||||
) : summary.won === false ? (
|
||||
<Skull size={28} />
|
||||
) : (
|
||||
<Gamepad2 size={28} />
|
||||
)}
|
||||
<div className={`absolute inset-0 rounded-2xl ${heroGradient} blur-md opacity-70`} />
|
||||
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}>
|
||||
{won ? (
|
||||
<Trophy size={30} />
|
||||
) : lost ? (
|
||||
<Skull size={30} />
|
||||
) : (
|
||||
<Gamepad2 size={30} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{summary.won === true
|
||||
? 'Победа! Время заработанных упражнений'
|
||||
: summary.won === false
|
||||
? 'Поражение. Но тело — нет'
|
||||
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
|
||||
{won
|
||||
? 'Victory · упражнения заработаны'
|
||||
: lost
|
||||
? 'Defeat · но тело — нет'
|
||||
: 'Матч завершён'}
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
{Math.floor(summary.durationMs / 60_000)} мин · {summary.results.length}{' '}
|
||||
<p className="text-[11px] text-muted mt-1.5">
|
||||
<span className="font-mono-num font-semibold text-text">
|
||||
{Math.floor(summary.durationMs / 60_000)}
|
||||
</span>{' '}
|
||||
мин · {summary.results.length}{' '}
|
||||
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||
{allDone ? (
|
||||
<span className="text-emerald-500 font-medium">всё выполнено</span>
|
||||
<span className="text-victory font-semibold uppercase tracking-wider">
|
||||
all clear
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-accent font-semibold">{remainingReps} ещё</span>
|
||||
<span className="text-accent font-bold font-mono-num">
|
||||
{remainingReps} осталось
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-2">
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-2 pb-2">
|
||||
{summary.results.map((r) => (
|
||||
<ChallengeRow
|
||||
key={r.challengeId}
|
||||
@@ -223,18 +314,24 @@ function MatchSummaryView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-3 flex items-center gap-2">
|
||||
<div className="flex-1 text-xs text-muted">
|
||||
Всего: <span className="text-text font-semibold">{totalReps}</span>{' '}
|
||||
повторений
|
||||
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40">
|
||||
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold">
|
||||
Total ·{' '}
|
||||
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
|
||||
{totalReps}
|
||||
</span>{' '}
|
||||
reps
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-11 px-5 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center gap-1.5 shadow-glow"
|
||||
className={[
|
||||
'h-11 px-5 rounded-xl text-white text-sm font-bold uppercase tracking-wider inline-flex items-center gap-1.5 transition-all hover:brightness-110',
|
||||
allDone ? 'bg-gradient-victory shadow-glow-victory' : 'bg-gradient-brand shadow-glow'
|
||||
].join(' ')}
|
||||
>
|
||||
{allDone ? (
|
||||
<>
|
||||
<Check size={16} /> Готово
|
||||
<Check size={16} /> GG
|
||||
</>
|
||||
) : (
|
||||
'Позже'
|
||||
@@ -262,27 +359,41 @@ function ChallengeRow({
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
|
||||
done
|
||||
? 'border-emerald-500/40 bg-emerald-500/10'
|
||||
: 'border-border bg-surface-elevated'
|
||||
? 'border-victory/40 bg-victory/10'
|
||||
: 'border-border/70 bg-surface-elevated/60'
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
|
||||
done ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent'
|
||||
done ? 'bg-victory/20 text-victory' : 'bg-accent/15 text-accent'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={result.icon} size={22} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={['font-medium truncate', done ? 'line-through opacity-60' : ''].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'font-display font-semibold tracking-wide truncate',
|
||||
done ? 'line-through opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{result.exerciseName}
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{result.statValue} {result.statLabel} → {result.name}
|
||||
<div className="text-[11px] text-muted mt-0.5">
|
||||
<span className="font-mono-num font-bold text-text">
|
||||
{result.statValue}
|
||||
</span>{' '}
|
||||
{result.statLabel} →{' '}
|
||||
<span className="text-accent">{result.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={['text-2xl font-bold tabular-nums', done ? 'text-emerald-500' : 'text-accent'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'font-mono-num text-2xl font-bold tabular-nums',
|
||||
done ? 'text-victory' : 'text-gradient-brand'
|
||||
].join(' ')}
|
||||
>
|
||||
{result.reps}
|
||||
</div>
|
||||
<button
|
||||
@@ -291,8 +402,8 @@ function ChallengeRow({
|
||||
className={[
|
||||
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
|
||||
done
|
||||
? 'bg-emerald-500 text-white cursor-default'
|
||||
: 'bg-accent text-white hover:brightness-110'
|
||||
? 'bg-victory text-white cursor-default'
|
||||
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow'
|
||||
].join(' ')}
|
||||
aria-label="Готово"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Check, Pencil, Trash2, Zap } from 'lucide-react'
|
||||
import type { Exercise, Tick } from '@shared/types'
|
||||
import { Icon } from '../lib/icon'
|
||||
import { formatCountdown, formatInterval } from '../lib/format'
|
||||
@@ -23,39 +23,100 @@ export function ExerciseCard({
|
||||
onMarkDone
|
||||
}: Props): JSX.Element {
|
||||
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
|
||||
const progressPct = (() => {
|
||||
const total = exercise.intervalMinutes * 60_000
|
||||
const remaining = Math.max(0, Math.min(total, ms))
|
||||
const elapsed = total - remaining
|
||||
return Math.max(0, Math.min(100, (elapsed / total) * 100))
|
||||
})()
|
||||
const total = exercise.intervalMinutes * 60_000
|
||||
const remaining = Math.max(0, Math.min(total, ms))
|
||||
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
||||
const isDue = ms <= 0 && exercise.enabled
|
||||
|
||||
// SVG cooldown ring math
|
||||
const R = 22
|
||||
const C = 2 * Math.PI * R
|
||||
const dashOffset = C * (1 - elapsedPct)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
|
||||
className={[
|
||||
'group rounded-2xl border bg-surface p-5 flex flex-col gap-4 transition-shadow',
|
||||
isDue ? 'border-accent shadow-glow' : 'border-border hover:shadow-soft'
|
||||
'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(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={[
|
||||
'w-12 h-12 rounded-xl grid place-items-center',
|
||||
exercise.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={exercise.icon} size={22} />
|
||||
{/* Glow corner accent */}
|
||||
<div
|
||||
className={[
|
||||
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity',
|
||||
exercise.enabled
|
||||
? 'bg-accent/15 opacity-100'
|
||||
: 'bg-muted/10 opacity-50'
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Hex-like rounded icon plaque with cooldown ring */}
|
||||
<div className="relative w-14 h-14 shrink-0">
|
||||
<svg
|
||||
className="absolute inset-0 -rotate-90"
|
||||
viewBox="0 0 56 56"
|
||||
width="56"
|
||||
height="56"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cooldownGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(var(--accent))" />
|
||||
<stop offset="100%" stopColor="rgb(var(--accent-2))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
className="cooldown-track"
|
||||
cx="28"
|
||||
cy="28"
|
||||
r={R}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{exercise.enabled && (
|
||||
<circle
|
||||
className="cooldown-fill"
|
||||
cx="28"
|
||||
cy="28"
|
||||
r={R}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-[7px] rounded-full grid place-items-center transition-colors',
|
||||
exercise.enabled
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={exercise.icon} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold leading-tight">{exercise.name}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
||||
<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>
|
||||
@@ -66,46 +127,49 @@ export function ExerciseCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs uppercase tracking-wider text-muted">
|
||||
Следующее
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||
Cooldown
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'text-sm font-mono font-semibold tabular-nums',
|
||||
'text-sm font-mono-num font-bold',
|
||||
isDue ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
|
||||
{exercise.enabled ? formatCountdown(ms) : 'PAUSED'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-surface-elevated overflow-hidden">
|
||||
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-accent"
|
||||
animate={{ width: `${exercise.enabled ? progressPct : 0}%` }}
|
||||
className={[
|
||||
'h-full rounded-full',
|
||||
isDue ? 'bg-gradient-brand' : 'bg-accent'
|
||||
].join(' ')}
|
||||
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }}
|
||||
transition={{ duration: 0.5, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="relative flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onMarkDone}
|
||||
className="flex-1 h-9 rounded-lg bg-accent/10 hover:bg-accent/20 text-accent text-xs font-medium inline-flex items-center justify-center gap-1.5"
|
||||
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"
|
||||
>
|
||||
<Check size={14} /> Сделал сейчас
|
||||
<Check size={14} /> Сделал
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
|
||||
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-red-500/15 hover:text-red-500 text-muted"
|
||||
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} />
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
ListChecks,
|
||||
Gamepad2,
|
||||
Target,
|
||||
Settings as SettingsIcon
|
||||
Settings as SettingsIcon,
|
||||
Dumbbell
|
||||
} from 'lucide-react'
|
||||
|
||||
const links = [
|
||||
@@ -17,19 +18,32 @@ const links = [
|
||||
|
||||
export function Sidebar(): JSX.Element {
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-border/60 bg-surface/40 flex flex-col">
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent grid place-items-center text-white font-bold shadow-glow">
|
||||
R
|
||||
<aside className="w-60 shrink-0 border-r border-border/60 bg-surface/40 backdrop-blur-sm flex flex-col relative">
|
||||
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
|
||||
<div className="relative px-5 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
|
||||
<div className="relative w-11 h-11 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
|
||||
<Dumbbell size={20} strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm leading-tight">Reminder</div>
|
||||
<div className="text-xs text-muted">Будь в движении</div>
|
||||
<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">
|
||||
Play hard · Train harder
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-3 flex flex-col gap-1">
|
||||
|
||||
<div className="relative px-5 pb-3">
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||
</div>
|
||||
|
||||
<nav className="relative px-3 flex flex-col gap-0.5">
|
||||
{links.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
@@ -37,20 +51,50 @@ export function Sidebar(): JSX.Element {
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
'group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted hover:text-text hover:bg-surface-elevated'
|
||||
? 'text-text bg-surface-elevated/80'
|
||||
: 'text-muted hover:text-text hover:bg-surface-elevated/50'
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={[
|
||||
'absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all',
|
||||
isActive
|
||||
? 'bg-gradient-to-b from-accent to-accent-2 opacity-100 shadow-glow'
|
||||
: 'opacity-0'
|
||||
].join(' ')}
|
||||
/>
|
||||
<Icon
|
||||
size={18}
|
||||
className={isActive ? 'text-accent' : ''}
|
||||
strokeWidth={isActive ? 2.4 : 2}
|
||||
/>
|
||||
<span className={isActive ? 'font-semibold' : ''}>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto p-4 text-xs text-muted">
|
||||
<div className="opacity-60">v0.1 · Windows 11</div>
|
||||
|
||||
<div className="relative mt-auto p-4">
|
||||
<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 · v0.1
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted/80 leading-snug">
|
||||
GSI-трекинг матчей активен
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Minus, X, Square } from 'lucide-react'
|
||||
import { Minus, X, Square, Activity } from 'lucide-react'
|
||||
|
||||
export function Titlebar({ title }: { title: string }): JSX.Element {
|
||||
return (
|
||||
<div className="titlebar-drag h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/60 backdrop-blur">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted">
|
||||
<span className="w-2 h-2 rounded-full bg-accent shadow-glow" />
|
||||
<span>{title}</span>
|
||||
<div className="titlebar-drag relative h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/50 backdrop-blur-md hud-scanlines">
|
||||
<div className="flex items-center gap-2 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="uppercase tracking-[0.18em] text-muted font-display font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="titlebar-nodrag flex items-center gap-1">
|
||||
<button
|
||||
@@ -24,7 +33,7 @@ export function Titlebar({ title }: { title: string }): JSX.Element {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.api.closeMain()}
|
||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white transition-colors text-muted"
|
||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white transition-colors text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={14} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
@@ -10,11 +10,13 @@ type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-accent text-white hover:brightness-110 active:brightness-95 shadow-soft',
|
||||
'bg-gradient-brand text-white shadow-glow hover:shadow-glow-lg hover:brightness-110 active:brightness-95',
|
||||
secondary:
|
||||
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border',
|
||||
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border hover:border-accent/40',
|
||||
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 shadow-soft'
|
||||
danger: 'bg-defeat text-white hover:brightness-110 shadow-soft',
|
||||
victory:
|
||||
'bg-gradient-victory text-white shadow-glow-victory hover:brightness-110'
|
||||
}
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
@@ -31,7 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
<button
|
||||
ref={ref}
|
||||
className={[
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-medium 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-2 rounded-xl font-semibold tracking-wide transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Pause, Play, Clock } from 'lucide-react'
|
||||
import {
|
||||
Plus,
|
||||
Pause,
|
||||
Play,
|
||||
Timer,
|
||||
Flame,
|
||||
Activity,
|
||||
Gamepad2,
|
||||
Trophy
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { ExerciseCard } from '../components/ExerciseCard'
|
||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
import { formatCountdown, formatInterval } from '../lib/format'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
@@ -16,16 +25,27 @@ export default function Dashboard(): JSX.Element {
|
||||
|
||||
const exercises = state?.exercises ?? []
|
||||
const settings = state?.settings
|
||||
const challenges = state?.challenges ?? []
|
||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const enabled = exercises.filter((e) => e.enabled)
|
||||
const next = enabled
|
||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||
.sort((a, b) => a.ms - b.ms)[0]
|
||||
const totalReps = enabled.reduce((s, e) => s + e.reps, 0)
|
||||
const avgInterval =
|
||||
enabled.length > 0
|
||||
? Math.round(
|
||||
enabled.reduce((s, e) => s + e.intervalMinutes, 0) / enabled.length
|
||||
)
|
||||
: 0
|
||||
return {
|
||||
total: exercises.length,
|
||||
active: enabled.length,
|
||||
nextMs: next?.ms ?? Infinity
|
||||
nextMs: next?.ms ?? Infinity,
|
||||
totalReps,
|
||||
avgInterval
|
||||
}
|
||||
}, [exercises, ticks])
|
||||
|
||||
@@ -63,18 +83,30 @@ export default function Dashboard(): JSX.Element {
|
||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||
}
|
||||
|
||||
const paused = !settings?.globalEnabled
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full">
|
||||
{/* Hero header */}
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{stats.active} активных из {stats.total} упражнений
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||
Mission control
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-4xl leading-none uppercase tracking-wide">
|
||||
<span className="text-gradient-brand">Дашборд</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
{stats.active} активных из {stats.total} упражнений ·{' '}
|
||||
<span className="text-text font-mono-num font-semibold">
|
||||
{stats.totalReps}
|
||||
</span>{' '}
|
||||
повторов за цикл
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={togglePause}>
|
||||
{settings?.globalEnabled ? (
|
||||
{!paused ? (
|
||||
<>
|
||||
<Pause size={16} /> Пауза
|
||||
</>
|
||||
@@ -90,27 +122,78 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-2xl border border-border bg-surface px-5 py-4 flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent/15 text-accent grid place-items-center">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted uppercase tracking-wider">
|
||||
Ближайшее напоминание
|
||||
</div>
|
||||
<div className="text-lg font-semibold mt-0.5">
|
||||
{stats.nextMs === Infinity
|
||||
? 'Нет активных упражнений'
|
||||
: `через ${formatCountdown(stats.nextMs)}`}
|
||||
</div>
|
||||
</div>
|
||||
{!settings?.globalEnabled && (
|
||||
<div className="px-3 py-1.5 rounded-full bg-amber-500/15 text-amber-500 text-xs font-medium">
|
||||
Напоминания на паузе
|
||||
</div>
|
||||
)}
|
||||
{/* HUD stat strip */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 mb-6">
|
||||
<HudStat
|
||||
icon={<Timer size={18} />}
|
||||
label="Cooldown"
|
||||
value={
|
||||
stats.nextMs === Infinity
|
||||
? '—'
|
||||
: stats.nextMs <= 0
|
||||
? 'READY'
|
||||
: formatCountdown(stats.nextMs)
|
||||
}
|
||||
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Activity size={18} />}
|
||||
label="Активных"
|
||||
value={`${stats.active}/${stats.total}`}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Flame size={18} />}
|
||||
label="Avg интервал"
|
||||
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'}
|
||||
/>
|
||||
<HudStat
|
||||
icon={<Gamepad2 size={18} />}
|
||||
label="Game tracking"
|
||||
value={gamesEnabled ? 'LIVE' : 'OFF'}
|
||||
accent={gamesEnabled}
|
||||
tone={gamesEnabled ? 'victory' : 'muted'}
|
||||
/>
|
||||
</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="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) => (
|
||||
@@ -128,8 +211,16 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
|
||||
{exercises.length === 0 && (
|
||||
<div className="mt-10 text-center text-muted">
|
||||
<p>Нет упражнений. Добавьте первое.</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -142,3 +233,54 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HudStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
tone = 'accent'
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
accent?: boolean
|
||||
tone?: 'accent' | 'victory' | 'muted'
|
||||
}): JSX.Element {
|
||||
const toneClasses =
|
||||
tone === 'victory'
|
||||
? 'text-victory bg-victory/15'
|
||||
: tone === 'muted'
|
||||
? 'text-muted bg-surface-elevated'
|
||||
: 'text-accent bg-accent/15'
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'relative rounded-2xl border bg-surface/60 backdrop-blur-sm px-4 py-3 overflow-hidden',
|
||||
accent ? 'border-accent/40 shadow-glow' : 'border-border/70'
|
||||
].join(' ')}
|
||||
>
|
||||
{accent && (
|
||||
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-accent/20 blur-2xl pointer-events-none" />
|
||||
)}
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div
|
||||
className={[
|
||||
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
|
||||
toneClasses
|
||||
].join(' ')}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-display font-bold text-xl tracking-wide truncate">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
|
||||
function hexToRgbString(hex: string): string {
|
||||
const cleaned = hex.replace('#', '').slice(0, 6).padEnd(6, '0')
|
||||
const r = parseInt(cleaned.slice(0, 2), 16)
|
||||
const g = parseInt(cleaned.slice(2, 4), 16)
|
||||
const b = parseInt(cleaned.slice(4, 6), 16)
|
||||
return `${r} ${g} ${b}`
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
|
||||
@@ -19,19 +11,8 @@ export function ThemeProvider({ children }: { children: ReactNode }): JSX.Elemen
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAccentColor().then((color) => {
|
||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
||||
document.documentElement.style.setProperty(
|
||||
'--accent-soft',
|
||||
hexToRgbString(color)
|
||||
)
|
||||
})
|
||||
const unsub = window.api.onAccentChanged((color) => {
|
||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
// Brand palette is fixed (cyan + violet neon). We deliberately do not
|
||||
// overwrite --accent with the OS accent — keeping the esports HUD identity.
|
||||
|
||||
useEffect(() => {
|
||||
const pref = settings?.theme ?? 'system'
|
||||
|
||||
@@ -3,30 +3,36 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Default accent (Windows blue), overridden at runtime via systemPreferences.getAccentColor */
|
||||
--accent: 91 141 239;
|
||||
--accent-soft: 91 141 239;
|
||||
/* Brand neon palette — overridden at runtime if user picks OS accent */
|
||||
--accent: 34 211 238; /* cyan-400 — primary energy */
|
||||
--accent-soft: 34 211 238;
|
||||
--accent-2: 168 85 247; /* violet-500 — gradient pair */
|
||||
--victory: 132 204 22; /* lime-500 — sport / done */
|
||||
--defeat: 244 63 94; /* rose-500 — danger */
|
||||
--xp: 250 204 21; /* amber-400 — streak */
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Light theme (default) */
|
||||
/* Light theme — kept clean and modern, sport vibe */
|
||||
:root {
|
||||
--bg: 245 247 251;
|
||||
--bg: 244 246 252;
|
||||
--bg-deep: 230 234 244;
|
||||
--surface: 255 255 255;
|
||||
--surface-elevated: 255 255 255;
|
||||
--border: 226 230 240;
|
||||
--text: 17 24 39;
|
||||
--muted: 107 114 128;
|
||||
--surface-elevated: 248 250 254;
|
||||
--border: 224 228 240;
|
||||
--text: 13 18 32;
|
||||
--muted: 102 112 134;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
/* Dark theme — esports HUD vibe (default for gamers) */
|
||||
.dark {
|
||||
--bg: 15 17 23;
|
||||
--surface: 24 27 35;
|
||||
--surface-elevated: 32 36 47;
|
||||
--border: 45 50 64;
|
||||
--text: 235 238 245;
|
||||
--muted: 148 156 173;
|
||||
--bg: 8 11 20;
|
||||
--bg-deep: 4 6 12;
|
||||
--surface: 16 20 33;
|
||||
--surface-elevated: 22 27 44;
|
||||
--border: 38 46 70;
|
||||
--text: 232 237 250;
|
||||
--muted: 138 150 178;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -39,11 +45,48 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
background: rgb(var(--bg));
|
||||
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
color: rgb(var(--text));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: rgb(var(--bg));
|
||||
background-image:
|
||||
radial-gradient(
|
||||
1200px 600px at 85% -10%,
|
||||
rgb(var(--accent) / 0.12),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(var(--accent-2) / 0.1),
|
||||
transparent 60%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
1200px 600px at 85% -10%,
|
||||
rgb(var(--accent) / 0.18),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 500px at -10% 110%,
|
||||
rgb(var(--accent-2) / 0.14),
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(180deg, rgb(var(--bg-deep)) 0%, rgb(var(--bg)) 100%);
|
||||
}
|
||||
|
||||
/* Display font for big numbers / sport headers */
|
||||
.font-display {
|
||||
font-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.font-mono-num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Custom titlebar drag region */
|
||||
@@ -64,9 +107,13 @@ body {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--muted) / 0.5);
|
||||
background: rgb(var(--accent) / 0.4);
|
||||
background-clip: padding-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@@ -74,20 +121,138 @@ body {
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgb(var(--accent) / 0.35);
|
||||
background: rgb(var(--accent) / 0.4);
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
/* Reminder-window root: rounded corners & subtle border */
|
||||
/* Reminder-window root: neon HUD frame */
|
||||
.reminder-shell {
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(var(--surface-elevated)) 0%,
|
||||
rgb(var(--surface)) 100%
|
||||
);
|
||||
box-shadow: 0 24px 60px -20px rgb(0 0 0 / 0.55);
|
||||
position: relative;
|
||||
border: 1px solid rgb(var(--accent) / 0.5);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% -20%,
|
||||
rgb(var(--accent) / 0.22),
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(180deg, rgb(var(--surface-elevated)) 0%, rgb(var(--surface)) 100%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--accent) / 0.15),
|
||||
0 20px 80px -20px rgb(var(--accent) / 0.45),
|
||||
0 24px 60px -20px rgb(0 0 0 / 0.6);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Soft scanline texture for HUD surfaces */
|
||||
.hud-scanlines {
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgb(var(--text) / 0.03) 0px,
|
||||
rgb(var(--text) / 0.03) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* Gradient text and gradient brand */
|
||||
.text-gradient-brand {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--accent)) 0%,
|
||||
rgb(var(--accent-2)) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.bg-gradient-brand {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--accent)) 0%,
|
||||
rgb(var(--accent-2)) 100%
|
||||
);
|
||||
}
|
||||
.bg-gradient-victory {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--victory)) 0%,
|
||||
rgb(var(--accent)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Neon border (animated gradient stroke for "due" / "active" cards) */
|
||||
.neon-border {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.neon-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--accent)) 0%,
|
||||
rgb(var(--accent-2)) 60%,
|
||||
rgb(var(--accent)) 100%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: neon-shift 6s linear infinite;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* HUD pulse — soft outer glow for "due" cards */
|
||||
.hud-pulse {
|
||||
animation: hud-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes neon-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
}
|
||||
@keyframes hud-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 0 rgb(var(--accent) / 0.45),
|
||||
0 12px 30px -10px rgb(var(--accent) / 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 6px rgb(var(--accent) / 0),
|
||||
0 18px 40px -10px rgb(var(--accent) / 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle dot-grid texture (sidebar / hero strip) */
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(
|
||||
rgb(var(--text) / 0.07) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
/* Cooldown ring SVG helpers */
|
||||
.cooldown-track {
|
||||
stroke: rgb(var(--border));
|
||||
}
|
||||
.cooldown-fill {
|
||||
stroke: url(#cooldownGrad);
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 6px rgb(var(--accent) / 0.6));
|
||||
transition: stroke-dashoffset 0.5s linear;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ export default {
|
||||
colors: {
|
||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
||||
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
|
||||
victory: 'rgb(var(--victory) / <alpha-value>)',
|
||||
defeat: 'rgb(var(--defeat) / <alpha-value>)',
|
||||
xp: 'rgb(var(--xp) / <alpha-value>)',
|
||||
bg: 'rgb(var(--bg) / <alpha-value>)',
|
||||
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
|
||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
|
||||
border: 'rgb(var(--border) / <alpha-value>)',
|
||||
@@ -15,19 +20,44 @@ export default {
|
||||
muted: 'rgb(var(--muted) / <alpha-value>)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Segoe UI', 'system-ui', 'sans-serif']
|
||||
sans: ['Inter', 'Segoe UI Variable', 'Segoe UI', 'system-ui', 'sans-serif'],
|
||||
display: ['Rajdhani', 'Inter', 'Segoe UI Variable', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'Menlo', 'monospace']
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 8px 30px -12px rgb(0 0 0 / 0.25)',
|
||||
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.5)'
|
||||
soft: '0 8px 30px -12px rgb(0 0 0 / 0.35)',
|
||||
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.55)',
|
||||
'glow-lg':
|
||||
'0 0 0 1px rgb(var(--accent) / 0.45), 0 18px 48px -12px rgb(var(--accent) / 0.7)',
|
||||
'glow-victory':
|
||||
'0 0 0 1px rgb(var(--victory) / 0.45), 0 12px 32px -10px rgb(var(--victory) / 0.55)',
|
||||
hud: '0 1px 0 rgb(var(--text) / 0.04) inset, 0 0 0 1px rgb(var(--border) / 0.8), 0 18px 40px -20px rgb(0 0 0 / 0.4)'
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-brand':
|
||||
'linear-gradient(135deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 100%)',
|
||||
'gradient-victory':
|
||||
'linear-gradient(135deg, rgb(var(--victory)) 0%, rgb(var(--accent)) 100%)',
|
||||
'gradient-defeat':
|
||||
'linear-gradient(135deg, rgb(var(--defeat)) 0%, rgb(var(--accent-2)) 100%)'
|
||||
},
|
||||
animation: {
|
||||
'pulse-ring': 'pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
'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.05)', opacity: '0.3' }
|
||||
'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