redesign(ui): phase 2 — все вторичные страницы и UI-примитивы

Распространили esports-HUD язык на оставшиеся экраны и общие UI:

- Modal: neon border accent/25 с двойным glow (cyan + violet),
  gradient-divider, gradient-stripe слева от заголовка, defeat hover
  на закрытие
- Switch: bg-gradient-brand с shadow-glow в on-состоянии
- Exercises: hero с gradient-text, статистика в моноширинном шрифте
  (active count, total reps за цикл), HUD-список с анимированным
  появлением, hover-row с accent tint
- Games: game cards с gradient orb-иконкой + glow на интегрированных,
  shadow-glow-victory + анимированная LIVE точка для активной
  интеграции, новые status badges (LIVE / READY / QUEUED / INSTALLED /
  NOT FOUND) в display-шрифте, ошибки в защитных цветах (xp/defeat)
- Challenges: hero + formula-row "stat × N → reps" в моноширинном
  шрифте, gradient-preview карточка в редакторе с большим итоговым
  числом в text-gradient-brand
- Settings: hero, секции с иконкой в accent-плашке, заголовки секций
  в display-шрифте uppercase tracking-wide
- ExerciseEditor: preview-карточка с gradient-orb иконкой в шапке
  модала, моноширинные input для чисел, scale-up на выбранной иконке

Все правки используют существующие токены из phase 1 — никаких новых
CSS-переменных или конфигов.

Откат phase 2 один:  git revert HEAD
Откат до начала редизайна:  git reset --hard 688a86b

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-16 18:42:05 +07:00
parent 4da83761d2
commit d418b9bbd2
7 changed files with 521 additions and 180 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Zap } from 'lucide-react'
import type { Exercise } from '@shared/types'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
@@ -27,7 +28,12 @@ type Props = {
onSave: (draft: Draft) => void
}
export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.Element {
export function ExerciseEditor({
open,
exercise,
onClose,
onSave
}: Props): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY)
useEffect(() => {
@@ -63,6 +69,32 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
}
>
<div className="space-y-5">
{/* Preview card */}
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden">
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
<div className="relative flex items-center gap-4">
<div className="relative w-14 h-14 shrink-0">
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
<div className="relative w-14 h-14 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
<Icon name={draft.icon} size={26} />
</div>
</div>
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
Preview
</div>
<div className="font-display font-bold text-lg uppercase tracking-wide truncate">
{draft.name || 'Без названия'}
</div>
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5 font-mono-num">
<Zap size={11} className="text-xp" />
<span className="font-bold text-text">{draft.reps}</span>
<span>повторов · каждые {draft.intervalMinutes} мин</span>
</div>
</div>
</div>
</div>
<Field label="Название">
<input
value={draft.name}
@@ -82,7 +114,7 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
onChange={(e) =>
setDraft({ ...draft, reps: Math.max(1, Number(e.target.value) || 1) })
}
className="input"
className="input font-mono-num font-semibold"
/>
</Field>
<Field label="Интервал (мин)">
@@ -96,7 +128,7 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
})
}
className="input"
className="input font-mono-num font-semibold"
/>
</Field>
</div>
@@ -109,10 +141,10 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
type="button"
onClick={() => setDraft({ ...draft, icon: name })}
className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
'h-10 w-10 grid place-items-center rounded-lg border transition-all',
draft.icon === name
? 'border-accent bg-accent/15 text-accent'
: 'border-border bg-surface-elevated text-muted hover:text-text'
? 'border-accent bg-accent/15 text-accent shadow-glow scale-105'
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
].join(' ')}
>
<Icon name={name} size={18} />
@@ -152,7 +184,7 @@ function Field({
}): JSX.Element {
return (
<label className="block">
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]">
{label}
</span>
{children}

View File

@@ -38,7 +38,7 @@ export function Modal({
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 grid place-items-center bg-black/40 backdrop-blur-sm"
className="fixed inset-0 z-50 grid place-items-center bg-black/60 backdrop-blur-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -48,30 +48,49 @@ export function Modal({
role="dialog"
aria-modal="true"
className={[
'w-full mx-4 bg-surface rounded-2xl border border-border shadow-soft flex flex-col',
'relative w-full mx-4 bg-surface rounded-2xl border border-accent/25 flex flex-col overflow-hidden',
sizeClass[size]
].join(' ')}
initial={{ scale: 0.96, y: 12, opacity: 0 }}
style={{
boxShadow:
'0 0 0 1px rgb(var(--accent) / 0.1), 0 24px 80px -20px rgb(var(--accent) / 0.35), 0 28px 60px -20px rgb(0 0 0 / 0.6)'
}}
initial={{ scale: 0.95, y: 16, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.96, y: 8, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
transition={{ type: 'spring', stiffness: 300, damping: 26 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
<h3 className="font-semibold text-base">{title}</h3>
{/* Glow accent corner */}
<div className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-56 h-56 rounded-full bg-accent-2/15 blur-3xl pointer-events-none" />
<div className="relative flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-2.5">
<span className="w-1 h-5 rounded-full bg-gradient-brand" />
<h3 className="font-display font-bold text-base uppercase tracking-wider">
{title}
</h3>
</div>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text"
className="w-8 h-8 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted transition-colors"
aria-label="Закрыть"
>
<X size={16} />
</button>
</div>
<div className="px-5 py-4 overflow-y-auto max-h-[70vh]">{children}</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]">
{children}
</div>
{footer && (
<div className="px-5 py-4 border-t border-border/60 flex justify-end gap-2">
{footer}
</div>
<>
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
<div className="relative px-5 py-4 flex justify-end gap-2">{footer}</div>
</>
)}
</motion.div>
</motion.div>

View File

@@ -15,14 +15,16 @@ export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Ele
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors duration-200 outline-none',
checked ? 'bg-accent' : 'bg-border',
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
checked
? 'bg-gradient-brand shadow-glow'
: 'bg-surface-elevated border border-border',
disabled ? 'opacity-50 cursor-not-allowed' : ''
].join(' ')}
>
<span
className={[
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform duration-200',
'inline-block h-5 w-5 transform rounded-full bg-white shadow-soft transition-transform duration-200',
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
'mt-0.5'
].join(' ')}

View File

@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, Gamepad2 } from 'lucide-react'
import {
Plus,
Pencil,
Trash2,
Gamepad2,
Target,
AlertTriangle
} from 'lucide-react'
import { motion } from 'framer-motion'
import { useAppStore } from '../store/appStore'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
@@ -35,13 +43,28 @@ export default function ChallengesPage(): JSX.Element {
return window.api.onGamesChanged(setGames)
}, [])
const activeCount = challenges.filter((c) => c.enabled).length
return (
<div className="p-8 overflow-y-auto h-full max-w-3xl">
<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">
После матча повторений = статистика × коэффициент
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Match rules
</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">
После матча · повторов = <span className="text-text font-mono-num">статистика × коэффициент</span>
{activeCount > 0 && (
<>
{' · '}
<span className="text-accent font-mono-num font-bold">
{activeCount} активных
</span>
</>
)}
</p>
</div>
<Button
@@ -54,64 +77,99 @@ export default function ChallengesPage(): JSX.Element {
</Button>
</div>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
{challenges.map((c, i) => (
<div
<motion.div
key={c.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }}
className={[
'flex items-center gap-4 px-5 py-4',
i < challenges.length - 1 ? 'border-b border-border/60' : ''
'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'
c.enabled
? 'bg-accent/15 text-accent'
: 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={c.icon} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{c.name}</div>
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5">
<Gamepad2 size={12} /> {GAME_NAMES[c.gameId]} ·{' '}
{STAT_LABELS[c.stat]} × {c.multiplier} = {c.exerciseName}
<div className="font-display font-semibold tracking-wide truncate text-base">
{c.name}
</div>
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5 flex-wrap">
<Gamepad2 size={11} className="text-muted" />
<span>{GAME_NAMES[c.gameId]}</span>
<span className="text-border">·</span>
<span className="font-mono-num">
<span className="text-text font-semibold">
{STAT_LABELS[c.stat]}
</span>{' '}
×{' '}
<span className="text-accent font-bold">{c.multiplier}</span>
</span>
<span className="text-border"></span>
<span className="text-text font-semibold">
{c.exerciseName}
</span>
</div>
</div>
<Switch
checked={c.enabled}
onChange={(v) => window.api.toggleChallenge(c.id, v)}
/>
<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"
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-red-500/15 hover:text-red-500 text-muted"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
<div className="flex items-center gap-1 opacity-0 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-12 text-center text-muted">
Челленджей пока нет. Добавь первый.
<div className="px-5 py-16 text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-3">
<Target size={26} />
</div>
<div className="font-display text-lg font-semibold uppercase tracking-wider">
Челленджей нет
</div>
<p className="text-sm text-muted mt-1">
Привяжи первое упражнение к статистике матча
</p>
</div>
)}
</div>
{games.length > 0 && !games.some((g) => g.enabled) && (
<div className="mt-6 rounded-xl bg-amber-500/10 border border-amber-500/30 p-4 text-sm">
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '}
<strong>Игры</strong>.
<div className="mt-6 rounded-xl bg-xp/10 border border-xp/30 p-4 text-sm flex items-start gap-2.5">
<AlertTriangle size={16} className="text-xp shrink-0 mt-0.5" />
<div>
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '}
<strong className="text-text">Игры</strong>.
</div>
</div>
)}
@@ -195,7 +253,9 @@ function ChallengeEditor({
<Field label="Игра">
<select
value={draft.gameId}
onChange={(e) => setDraft({ ...draft, gameId: e.target.value as GameId })}
onChange={(e) =>
setDraft({ ...draft, gameId: e.target.value as GameId })
}
className="input"
>
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
@@ -210,7 +270,9 @@ function ChallengeEditor({
<Field label="Стат">
<select
value={draft.stat}
onChange={(e) => setDraft({ ...draft, stat: e.target.value as GameStat })}
onChange={(e) =>
setDraft({ ...draft, stat: e.target.value as GameStat })
}
className="input"
>
{GAME_STATS[draft.gameId].map((s) => (
@@ -227,7 +289,10 @@ function ChallengeEditor({
min="0.5"
value={draft.multiplier}
onChange={(e) =>
setDraft({ ...draft, multiplier: Math.max(0.5, Number(e.target.value) || 1) })
setDraft({
...draft,
multiplier: Math.max(0.5, Number(e.target.value) || 1)
})
}
className="input"
/>
@@ -237,7 +302,9 @@ function ChallengeEditor({
<Field label="Упражнение">
<input
value={draft.exerciseName}
onChange={(e) => setDraft({ ...draft, exerciseName: e.target.value })}
onChange={(e) =>
setDraft({ ...draft, exerciseName: e.target.value })
}
placeholder="Например, приседания"
className="input"
/>
@@ -253,8 +320,8 @@ function ChallengeEditor({
className={[
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
draft.icon === name
? 'border-accent bg-accent/15 text-accent'
: 'border-border bg-surface-elevated text-muted hover:text-text'
? 'border-accent bg-accent/15 text-accent shadow-glow'
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
].join(' ')}
>
<Icon name={name} size={18} />
@@ -263,10 +330,27 @@ function ChallengeEditor({
</div>
</Field>
<div className="rounded-xl bg-surface-elevated p-4 text-sm text-muted">
Пример: 5 {STAT_LABELS[draft.stat]} × {draft.multiplier} ={' '}
<span className="text-accent font-semibold">{previewReps}</span>{' '}
{draft.exerciseName.toLowerCase()}
{/* Formula preview */}
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden">
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
<div className="relative">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold mb-2">
Preview · если 5 событий
</div>
<div className="flex items-center gap-2 text-sm font-mono-num">
<span className="font-bold text-text">5</span>
<span className="text-muted">{STAT_LABELS[draft.stat]}</span>
<span className="text-muted">×</span>
<span className="font-bold text-accent">{draft.multiplier}</span>
<span className="text-muted">=</span>
<span className="text-3xl font-bold text-gradient-brand leading-none ml-1">
{previewReps}
</span>
<span className="text-muted ml-1">
{draft.exerciseName.toLowerCase() || 'упражнений'}
</span>
</div>
</div>
</div>
</div>
<style>{`
@@ -303,7 +387,7 @@ function Field({
}): JSX.Element {
return (
<label className="block">
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
<span className="block text-[10px] font-display font-semibold text-muted mb-1.5 uppercase tracking-[0.18em]">
{label}
</span>
{children}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { Plus, Pencil, Trash2, ListChecks } from 'lucide-react'
import { motion } from 'framer-motion'
import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
@@ -13,13 +14,30 @@ export default function Exercises(): JSX.Element {
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const enabledCount = exercises.filter((e) => e.enabled).length
const totalReps = exercises
.filter((e) => e.enabled)
.reduce((s, e) => s + e.reps, 0)
return (
<div className="p-8 overflow-y-auto h-full">
<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">
Управляйте всеми упражнениями в одном месте
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Loadout
</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">
<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
@@ -32,54 +50,88 @@ export default function Exercises(): JSX.Element {
</Button>
</div>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
{exercises.map((ex, i) => (
<div
<motion.div
key={ex.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.02 }}
className={[
'flex items-center gap-4 px-5 py-4',
i < exercises.length - 1 ? 'border-b border-border/60' : ''
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
ex.enabled
? 'hover:bg-accent/[0.04]'
: 'opacity-70 hover:opacity-100',
i < exercises.length - 1 ? 'border-b border-border/40' : ''
].join(' ')}
>
<div
className={[
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
ex.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
'w-11 h-11 rounded-xl grid place-items-center shrink-0 transition-colors',
ex.enabled
? 'bg-accent/15 text-accent'
: 'bg-surface-elevated text-muted'
].join(' ')}
>
<Icon name={ex.icon} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{ex.name}</div>
<div className="text-xs text-muted mt-0.5">
{ex.reps} раз · каждые {formatInterval(ex.intervalMinutes)}
<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)}
/>
<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"
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-red-500/15 hover:text-red-500 text-muted"
aria-label="Удалить"
>
<Trash2 size={15} />
</button>
</div>
<div className="flex items-center gap-1 opacity-0 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-12 text-center text-muted">Список пуст</div>
<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>

View File

@@ -4,8 +4,12 @@ import {
Trash2,
RefreshCw,
CheckCircle2,
Hourglass
Hourglass,
Gamepad2,
Radio,
AlertTriangle
} from 'lucide-react'
import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import type { GameId, GameStatus } from '@shared/types'
@@ -51,13 +55,28 @@ export default function GamesPage(): JSX.Element {
}
}
const liveCount = games.filter((g) => g.enabled && g.integrationActive).length
return (
<div className="p-8 overflow-y-auto h-full max-w-3xl">
<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">
Подключите свою игру приложение будет триггерить челленджи после матча
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Game integrations
</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">
Подключи игру челленджи сработают сразу после матча
{liveCount > 0 && (
<>
{' · '}
<span className="text-victory font-mono-num font-bold">
{liveCount} LIVE
</span>
</>
)}
</p>
</div>
<Button variant="secondary" onClick={refresh}>
@@ -66,18 +85,28 @@ export default function GamesPage(): JSX.Element {
</div>
<div className="space-y-4">
{games.map((g) => (
<GameRow
{games.map((g, i) => (
<motion.div
key={g.id}
game={g}
busy={busy === g.id}
onInstall={() => install(g.id)}
onUninstall={() => uninstall(g.id)}
onToggle={(v) => toggle(g.id, v)}
/>
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="text-muted text-sm">Ищем установленные игры</div>
<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>
)}
</div>
@@ -99,37 +128,72 @@ function GameRow({
onUninstall: () => void
onToggle: (v: boolean) => void
}): JSX.Element {
const isLive =
game.installed &&
game.integrationActive &&
game.launchOptionStatus === 'applied' &&
game.enabled
return (
<div className="rounded-2xl border border-border bg-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-semibold text-lg">{game.name}</h3>
{game.installed ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium">
Установлена
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-medium">
Не найдена
</span>
)}
{game.integrationActive && game.launchOptionStatus === 'applied' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium inline-flex items-center gap-1">
<CheckCircle2 size={12} /> Готово к работе
</span>
)}
{game.integrationActive && game.launchOptionStatus === 'queued' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-500 font-medium inline-flex items-center gap-1">
<Hourglass size={12} /> Ждём закрытия Steam
</span>
<div
className={[
'relative rounded-2xl border bg-surface/70 backdrop-blur-sm p-5 overflow-hidden transition-colors',
isLive
? 'border-victory/40 shadow-glow-victory'
: game.integrationActive
? 'border-accent/30'
: 'border-border/70 hover:border-accent/30'
].join(' ')}
>
{/* Glow corner */}
<div
className={[
'absolute -top-10 -right-10 w-40 h-40 rounded-full blur-3xl pointer-events-none',
isLive ? 'bg-victory/20' : game.integrationActive ? 'bg-accent/15' : ''
].join(' ')}
/>
<div className="relative flex items-start justify-between gap-4">
<div className="flex items-start gap-4 min-w-0 flex-1">
{/* Game icon plaque */}
<div className="relative shrink-0">
<div
className={[
'absolute inset-0 rounded-2xl blur-md opacity-60',
isLive
? 'bg-gradient-victory'
: game.integrationActive
? 'bg-gradient-brand'
: ''
].join(' ')}
/>
<div
className={[
'relative w-14 h-14 rounded-2xl grid place-items-center text-white',
isLive
? 'bg-gradient-victory shadow-glow-victory'
: game.integrationActive
? 'bg-gradient-brand shadow-glow'
: 'bg-surface-elevated text-muted border border-border'
].join(' ')}
>
<Gamepad2 size={26} />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-display font-bold text-lg uppercase tracking-wide">
{game.name}
</h3>
<StatusBadge game={game} isLive={isLive} />
</div>
{game.installPath && (
<div className="text-[11px] text-muted mt-1.5 truncate font-mono opacity-70">
{game.installPath}
</div>
)}
</div>
{game.installPath && (
<div className="text-xs text-muted mt-1 truncate font-mono">
{game.installPath}
</div>
)}
</div>
{game.installed && game.integrationActive && (
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
@@ -137,25 +201,30 @@ function GameRow({
</div>
{game.integrationActive && game.launchOptionStatus === 'queued' && (
<div className="mt-4 rounded-xl bg-amber-500/10 border border-amber-500/30 p-3 text-sm">
Steam сейчас запущен. Параметр запуска{' '}
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam ничего делать не
нужно.
<div className="relative mt-4 rounded-xl bg-xp/10 border border-xp/30 p-3 text-sm flex items-start gap-2.5">
<Hourglass size={16} className="text-xp shrink-0 mt-0.5" />
<div>
Steam запущен. Параметр{' '}
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam.
</div>
</div>
)}
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
<div className="mt-4 rounded-xl bg-red-500/10 border border-red-500/30 p-3 text-sm">
В Steam нет ни одного залогиненного аккаунта (отсутствует папка{' '}
<code className="font-mono text-xs">userdata</code>). Запусти Steam один
раз, чтобы он создал конфиг потом снова нажми «Установить интеграцию».
<div className="relative mt-4 rounded-xl bg-defeat/10 border border-defeat/30 p-3 text-sm flex items-start gap-2.5">
<AlertTriangle size={16} className="text-defeat shrink-0 mt-0.5" />
<div>
В Steam нет залогиненного аккаунта (нет папки{' '}
<code className="font-mono text-xs">userdata</code>). Запусти Steam
один раз потом снова нажми «Установить интеграцию».
</div>
</div>
)}
<div className="flex items-center gap-2 mt-4">
<div className="relative flex items-center gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy}>
<Download size={16} /> Установить интеграцию
@@ -176,30 +245,81 @@ function GameRow({
)
}
function StatusBadge({
game,
isLive
}: {
game: GameStatus
isLive: boolean
}): JSX.Element {
if (isLive) {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-victory/15 text-victory font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-70 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-victory" />
</span>
Live
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'applied') {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
<CheckCircle2 size={11} /> Ready
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'queued') {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-xp/15 text-xp font-display font-bold uppercase tracking-widest inline-flex items-center gap-1.5">
<Radio size={11} /> Queued
</span>
)
}
if (game.installed) {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-text font-display font-bold uppercase tracking-widest">
Installed
</span>
)
}
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-display font-bold uppercase tracking-widest">
Not found
</span>
)
}
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const [open, setOpen] = useState(false)
const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null
return (
<div className="mt-8 pt-6 border-t border-border/60">
<div className="mt-8 pt-6 border-t border-border/40">
<button
onClick={() => setOpen(!open)}
className="text-xs text-muted hover:text-text font-mono"
className="text-[10px] uppercase tracking-[0.18em] text-muted hover:text-accent font-mono font-semibold transition-colors"
>
{open ? '▾' : '▸'} dev: симулировать конец матча
{open ? '▾' : '▸'} dev · симулировать конец матча
</button>
{open && (
<div className="mt-3 flex flex-wrap gap-2">
{([
{ label: '5 смертей', stats: { deaths: 5 } },
{ label: '10 смертей', stats: { deaths: 10 } },
{ label: '15 убийств', stats: { kills: 15 } },
{ label: 'KDA 8/3/12', stats: { kills: 8, deaths: 3, assists: 12 } }
] as { label: string; stats: Record<string, number> }[]).map((p) => (
{(
[
{ label: '5 смертей', stats: { deaths: 5 } },
{ label: '10 смертей', stats: { deaths: 10 } },
{ label: '15 убийств', stats: { kills: 15 } },
{
label: 'KDA 8/3/12',
stats: { kills: 8, deaths: 3, assists: 12 }
}
] as { label: string; stats: Record<string, number> }[]
).map((p) => (
<button
key={p.label}
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-border/60 text-text"
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"
>
{p.label}
</button>

View File

@@ -1,10 +1,16 @@
import { Bell, Monitor, Palette } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
if (!settings) return <div className="p-8">Загрузка</div>
if (!settings)
return (
<div className="p-8 text-muted font-display uppercase tracking-wider">
Загрузка
</div>
)
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -12,10 +18,19 @@ export default function SettingsPage(): JSX.Element {
return (
<div className="p-8 overflow-y-auto h-full max-w-2xl">
<h1 className="text-2xl font-bold mb-1">Настройки</h1>
<p className="text-sm text-muted mb-8">Настройте поведение приложения</p>
<div className="mb-8">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Config
</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">
Тонкая настройка поведения приложения
</p>
</div>
<Section title="Напоминания">
<Section title="Напоминания" icon={<Bell size={14} />}>
<SelectRow
label="Режим уведомления"
hint="Как должно выглядеть напоминание"
@@ -34,7 +49,7 @@ export default function SettingsPage(): JSX.Element {
onChange={(v) => patch({ soundEnabled: v })}
/>
<SelectRow
label="Интервал кнопки «Отложить»"
label="Интервал «Отложить»"
hint="На сколько минут откладывать при нажатии «Отложить»"
value={String(settings.snoozeMinutes)}
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
@@ -48,7 +63,7 @@ export default function SettingsPage(): JSX.Element {
/>
</Section>
<Section title="Окно и трей">
<Section title="Окно и трей" icon={<Monitor size={14} />}>
<ToggleRow
label="Сворачивать в трей"
hint="При закрытии окна приложение остаётся работать в системном трее"
@@ -70,9 +85,10 @@ export default function SettingsPage(): JSX.Element {
/>
</Section>
<Section title="Внешний вид">
<Section title="Внешний вид" icon={<Palette size={14} />}>
<SelectRow
label="Тема"
hint="Тёмная — родная esports-эстетика приложения"
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
@@ -88,17 +104,24 @@ export default function SettingsPage(): JSX.Element {
function Section({
title,
icon,
children
}: {
title: string
icon: React.ReactNode
children: React.ReactNode
}): JSX.Element {
return (
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-muted font-semibold mb-3">
{title}
</h2>
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
<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>
@@ -119,9 +142,16 @@ function ToggleRow({
disabled?: boolean
}): JSX.Element {
return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
<div
className={[
'flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors',
disabled ? 'opacity-50' : 'hover:bg-accent/[0.03]'
].join(' ')}
>
<div className="flex-1">
<div className="font-medium text-sm">{label}</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>
<Switch checked={checked} onChange={onChange} disabled={disabled} />
@@ -143,15 +173,17 @@ function SelectRow({
options: { value: string; label: string }[]
}): JSX.Element {
return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/40 last:border-b-0 transition-colors hover:bg-accent/[0.03]">
<div className="flex-1">
<div className="font-medium text-sm">{label}</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>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent"
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"
>
{options.map((o) => (
<option key={o.value} value={o.value}>