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:
@@ -1,4 +1,5 @@
|
|||||||
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'
|
||||||
@@ -27,7 +28,12 @@ type Props = {
|
|||||||
onSave: (draft: Draft) => void
|
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)
|
const [draft, setDraft] = useState<Draft>(EMPTY)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +69,32 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<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="Название">
|
<Field label="Название">
|
||||||
<input
|
<input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@@ -82,7 +114,7 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
|
|||||||
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"
|
className="input font-mono-num font-semibold"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Интервал (мин)">
|
<Field label="Интервал (мин)">
|
||||||
@@ -96,7 +128,7 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
|
|||||||
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
|
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="input"
|
className="input font-mono-num font-semibold"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,10 +141,10 @@ export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.
|
|||||||
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-lg border transition-all',
|
||||||
draft.icon === name
|
draft.icon === name
|
||||||
? 'border-accent bg-accent/15 text-accent'
|
? 'border-accent bg-accent/15 text-accent shadow-glow scale-105'
|
||||||
: 'border-border bg-surface-elevated text-muted hover:text-text'
|
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={name} size={18} />
|
<Icon name={name} size={18} />
|
||||||
@@ -152,7 +184,7 @@ function Field({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Modal({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -48,30 +48,49 @@ export function Modal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={[
|
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]
|
sizeClass[size]
|
||||||
].join(' ')}
|
].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 }}
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||||
exit={{ scale: 0.96, y: 8, opacity: 0 }}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
|
{/* Glow accent corner */}
|
||||||
<h3 className="font-semibold text-base">{title}</h3>
|
<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
|
<button
|
||||||
onClick={onClose}
|
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="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
||||||
{footer && (
|
|
||||||
<div className="px-5 py-4 border-t border-border/60 flex justify-end gap-2">
|
<div className="relative px-5 py-4 overflow-y-auto max-h-[70vh]">
|
||||||
{footer}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||||
|
<div className="relative px-5 py-4 flex justify-end gap-2">{footer}</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ 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-colors duration-200 outline-none',
|
'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-accent' : 'bg-border',
|
checked
|
||||||
|
? 'bg-gradient-brand shadow-glow'
|
||||||
|
: 'bg-surface-elevated border border-border',
|
||||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={[
|
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',
|
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
|
||||||
'mt-0.5'
|
'mt-0.5'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { 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'
|
||||||
@@ -35,13 +43,28 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
return window.api.onGamesChanged(setGames)
|
return window.api.onGamesChanged(setGames)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const activeCount = challenges.filter((c) => c.enabled).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
||||||
<div className="flex items-end justify-between mb-6">
|
<div className="flex items-end justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Челленджи</h1>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||||
<p className="text-sm text-muted mt-1">
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -54,64 +77,99 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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) => (
|
{challenges.map((c, i) => (
|
||||||
<div
|
<motion.div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.03 }}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center gap-4 px-5 py-4',
|
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
||||||
i < challenges.length - 1 ? 'border-b border-border/60' : ''
|
c.enabled
|
||||||
|
? 'hover:bg-accent/[0.04]'
|
||||||
|
: 'opacity-70 hover:opacity-100',
|
||||||
|
i < challenges.length - 1 ? 'border-b border-border/40' : ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
'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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={c.icon} size={20} />
|
<Icon name={c.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{c.name}</div>
|
<div className="font-display font-semibold tracking-wide truncate text-base">
|
||||||
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5">
|
{c.name}
|
||||||
<Gamepad2 size={12} /> {GAME_NAMES[c.gameId]} ·{' '}
|
</div>
|
||||||
{STAT_LABELS[c.stat]} × {c.multiplier} = {c.exerciseName}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={c.enabled}
|
checked={c.enabled}
|
||||||
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(c)
|
setEditing(c)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}}
|
}}
|
||||||
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="Редактировать"
|
aria-label="Редактировать"
|
||||||
>
|
>
|
||||||
<Pencil size={15} />
|
<Pencil size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.api.deleteChallenge(c.id)}
|
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"
|
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{challenges.length === 0 && (
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{games.length > 0 && !games.some((g) => g.enabled) && (
|
{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">
|
<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>Игры</strong>.
|
<strong className="text-text">Игры</strong>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -195,7 +253,9 @@ function ChallengeEditor({
|
|||||||
<Field label="Игра">
|
<Field label="Игра">
|
||||||
<select
|
<select
|
||||||
value={draft.gameId}
|
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"
|
className="input"
|
||||||
>
|
>
|
||||||
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
|
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
|
||||||
@@ -210,7 +270,9 @@ function ChallengeEditor({
|
|||||||
<Field label="Стат">
|
<Field label="Стат">
|
||||||
<select
|
<select
|
||||||
value={draft.stat}
|
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"
|
className="input"
|
||||||
>
|
>
|
||||||
{GAME_STATS[draft.gameId].map((s) => (
|
{GAME_STATS[draft.gameId].map((s) => (
|
||||||
@@ -227,7 +289,10 @@ function ChallengeEditor({
|
|||||||
min="0.5"
|
min="0.5"
|
||||||
value={draft.multiplier}
|
value={draft.multiplier}
|
||||||
onChange={(e) =>
|
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"
|
className="input"
|
||||||
/>
|
/>
|
||||||
@@ -237,7 +302,9 @@ function ChallengeEditor({
|
|||||||
<Field label="Упражнение">
|
<Field label="Упражнение">
|
||||||
<input
|
<input
|
||||||
value={draft.exerciseName}
|
value={draft.exerciseName}
|
||||||
onChange={(e) => setDraft({ ...draft, exerciseName: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, exerciseName: e.target.value })
|
||||||
|
}
|
||||||
placeholder="Например, приседания"
|
placeholder="Например, приседания"
|
||||||
className="input"
|
className="input"
|
||||||
/>
|
/>
|
||||||
@@ -253,8 +320,8 @@ function ChallengeEditor({
|
|||||||
className={[
|
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-colors',
|
||||||
draft.icon === name
|
draft.icon === name
|
||||||
? 'border-accent bg-accent/15 text-accent'
|
? 'border-accent bg-accent/15 text-accent shadow-glow'
|
||||||
: 'border-border bg-surface-elevated text-muted hover:text-text'
|
: 'border-border bg-surface-elevated text-muted hover:text-text hover:border-accent/40'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={name} size={18} />
|
<Icon name={name} size={18} />
|
||||||
@@ -263,10 +330,27 @@ function ChallengeEditor({
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="rounded-xl bg-surface-elevated p-4 text-sm text-muted">
|
{/* Formula preview */}
|
||||||
Пример: 5 {STAT_LABELS[draft.stat]} × {draft.multiplier} ={' '}
|
<div className="relative rounded-xl bg-gradient-to-br from-accent/10 to-accent-2/10 border border-accent/30 p-4 overflow-hidden">
|
||||||
<span className="text-accent font-semibold">{previewReps}</span>{' '}
|
<div className="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-accent/20 blur-3xl pointer-events-none" />
|
||||||
{draft.exerciseName.toLowerCase()}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -303,7 +387,7 @@ function Field({
|
|||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
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 { 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'
|
||||||
@@ -13,13 +14,30 @@ 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 totalReps = exercises
|
||||||
|
.filter((e) => e.enabled)
|
||||||
|
.reduce((s, e) => s + e.reps, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 overflow-y-auto h-full">
|
<div className="p-8 overflow-y-auto h-full">
|
||||||
<div className="flex items-end justify-between mb-6">
|
<div className="flex items-end justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Упражнения</h1>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||||
<p className="text-sm text-muted mt-1">
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -32,54 +50,88 @@ export default function Exercises(): JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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) => (
|
{exercises.map((ex, i) => (
|
||||||
<div
|
<motion.div
|
||||||
key={ex.id}
|
key={ex.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.02 }}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center gap-4 px-5 py-4',
|
'group flex items-center gap-4 px-5 py-3.5 transition-colors',
|
||||||
i < exercises.length - 1 ? 'border-b border-border/60' : ''
|
ex.enabled
|
||||||
|
? 'hover:bg-accent/[0.04]'
|
||||||
|
: 'opacity-70 hover:opacity-100',
|
||||||
|
i < exercises.length - 1 ? 'border-b border-border/40' : ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
'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'
|
ex.enabled
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'bg-surface-elevated text-muted'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={ex.icon} size={20} />
|
<Icon name={ex.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{ex.name}</div>
|
<div className="font-display font-semibold tracking-wide truncate text-base">
|
||||||
<div className="text-xs text-muted mt-0.5">
|
{ex.name}
|
||||||
{ex.reps} раз · каждые {formatInterval(ex.intervalMinutes)}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={ex.enabled}
|
checked={ex.enabled}
|
||||||
onChange={(v) => window.api.toggleExercise(ex.id, v)}
|
onChange={(v) => window.api.toggleExercise(ex.id, v)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(ex)
|
setEditing(ex)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}}
|
}}
|
||||||
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="Редактировать"
|
aria-label="Редактировать"
|
||||||
>
|
>
|
||||||
<Pencil size={15} />
|
<Pencil size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.api.deleteExercise(ex.id)}
|
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"
|
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{exercises.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Hourglass
|
Hourglass,
|
||||||
|
Gamepad2,
|
||||||
|
Radio,
|
||||||
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
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 type { GameId, GameStatus } from '@shared/types'
|
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 (
|
return (
|
||||||
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
||||||
<div className="flex items-end justify-between mb-6">
|
<div className="flex items-end justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Игры</h1>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||||
<p className="text-sm text-muted mt-1">
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={refresh}>
|
<Button variant="secondary" onClick={refresh}>
|
||||||
@@ -66,18 +85,28 @@ export default function GamesPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{games.map((g) => (
|
{games.map((g, i) => (
|
||||||
<GameRow
|
<motion.div
|
||||||
key={g.id}
|
key={g.id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
>
|
||||||
|
<GameRow
|
||||||
game={g}
|
game={g}
|
||||||
busy={busy === g.id}
|
busy={busy === g.id}
|
||||||
onInstall={() => install(g.id)}
|
onInstall={() => install(g.id)}
|
||||||
onUninstall={() => uninstall(g.id)}
|
onUninstall={() => uninstall(g.id)}
|
||||||
onToggle={(v) => toggle(g.id, v)}
|
onToggle={(v) => toggle(g.id, v)}
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{games.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@@ -99,63 +128,103 @@ function GameRow({
|
|||||||
onUninstall: () => void
|
onUninstall: () => void
|
||||||
onToggle: (v: boolean) => void
|
onToggle: (v: boolean) => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const isLive =
|
||||||
|
game.installed &&
|
||||||
|
game.integrationActive &&
|
||||||
|
game.launchOptionStatus === 'applied' &&
|
||||||
|
game.enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border bg-surface p-5">
|
<div
|
||||||
<div className="flex items-start justify-between gap-4">
|
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-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold text-lg">{game.name}</h3>
|
<h3 className="font-display font-bold text-lg uppercase tracking-wide">
|
||||||
{game.installed ? (
|
{game.name}
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium">
|
</h3>
|
||||||
Установлена
|
<StatusBadge game={game} isLive={isLive} />
|
||||||
</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>
|
</div>
|
||||||
{game.installPath && (
|
{game.installPath && (
|
||||||
<div className="text-xs text-muted mt-1 truncate font-mono">
|
<div className="text-[11px] text-muted mt-1.5 truncate font-mono opacity-70">
|
||||||
{game.installPath}
|
{game.installPath}
|
||||||
</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="mt-4 rounded-xl bg-amber-500/10 border border-amber-500/30 p-3 text-sm">
|
<div className="relative mt-4 rounded-xl bg-xp/10 border border-xp/30 p-3 text-sm flex items-start gap-2.5">
|
||||||
Steam сейчас запущен. Параметр запуска{' '}
|
<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">
|
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
|
||||||
{game.launchOption}
|
{game.launchOption}
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
пропишется автоматически при следующем закрытии Steam — ничего делать не
|
пропишется автоматически при следующем закрытии Steam.
|
||||||
нужно.
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
|
{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">
|
<div className="relative mt-4 rounded-xl bg-defeat/10 border border-defeat/30 p-3 text-sm flex items-start gap-2.5">
|
||||||
В Steam нет ни одного залогиненного аккаунта (отсутствует папка{' '}
|
<AlertTriangle size={16} className="text-defeat shrink-0 mt-0.5" />
|
||||||
<code className="font-mono text-xs">userdata</code>). Запусти Steam один
|
<div>
|
||||||
раз, чтобы он создал конфиг — потом снова нажми «Установить интеграцию».
|
В Steam нет залогиненного аккаунта (нет папки{' '}
|
||||||
|
<code className="font-mono text-xs">userdata</code>). Запусти Steam
|
||||||
|
один раз — потом снова нажми «Установить интеграцию».
|
||||||
|
</div>
|
||||||
</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 && (
|
{game.installed && !game.integrationActive && (
|
||||||
<Button onClick={onInstall} disabled={busy}>
|
<Button onClick={onInstall} disabled={busy}>
|
||||||
<Download size={16} /> Установить интеграцию
|
<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 {
|
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
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/60">
|
<div className="mt-8 pt-6 border-t border-border/40">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
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>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{([
|
{(
|
||||||
|
[
|
||||||
{ label: '5 смертей', stats: { deaths: 5 } },
|
{ label: '5 смертей', stats: { deaths: 5 } },
|
||||||
{ label: '10 смертей', stats: { deaths: 10 } },
|
{ label: '10 смертей', stats: { deaths: 10 } },
|
||||||
{ label: '15 убийств', stats: { kills: 15 } },
|
{ 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: 'KDA 8/3/12',
|
||||||
|
stats: { kills: 8, deaths: 3, assists: 12 }
|
||||||
|
}
|
||||||
|
] as { label: string; stats: Record<string, number> }[]
|
||||||
|
).map((p) => (
|
||||||
<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-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}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
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 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) 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 => {
|
const patch = (p: Partial<SettingsType>): void => {
|
||||||
window.api.updateSettings(p)
|
window.api.updateSettings(p)
|
||||||
@@ -12,10 +18,19 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 overflow-y-auto h-full max-w-2xl">
|
<div className="p-8 overflow-y-auto h-full max-w-2xl">
|
||||||
<h1 className="text-2xl font-bold mb-1">Настройки</h1>
|
<div className="mb-8">
|
||||||
<p className="text-sm text-muted mb-8">Настройте поведение приложения</p>
|
<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
|
<SelectRow
|
||||||
label="Режим уведомления"
|
label="Режим уведомления"
|
||||||
hint="Как должно выглядеть напоминание"
|
hint="Как должно выглядеть напоминание"
|
||||||
@@ -34,7 +49,7 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
onChange={(v) => patch({ soundEnabled: v })}
|
onChange={(v) => patch({ soundEnabled: v })}
|
||||||
/>
|
/>
|
||||||
<SelectRow
|
<SelectRow
|
||||||
label="Интервал кнопки «Отложить»"
|
label="Интервал «Отложить»"
|
||||||
hint="На сколько минут откладывать при нажатии «Отложить»"
|
hint="На сколько минут откладывать при нажатии «Отложить»"
|
||||||
value={String(settings.snoozeMinutes)}
|
value={String(settings.snoozeMinutes)}
|
||||||
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
||||||
@@ -48,7 +63,7 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Окно и трей">
|
<Section title="Окно и трей" icon={<Monitor size={14} />}>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Сворачивать в трей"
|
label="Сворачивать в трей"
|
||||||
hint="При закрытии окна приложение остаётся работать в системном трее"
|
hint="При закрытии окна приложение остаётся работать в системном трее"
|
||||||
@@ -70,9 +85,10 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Внешний вид">
|
<Section title="Внешний вид" icon={<Palette size={14} />}>
|
||||||
<SelectRow
|
<SelectRow
|
||||||
label="Тема"
|
label="Тема"
|
||||||
|
hint="Тёмная — родная esports-эстетика приложения"
|
||||||
value={settings.theme}
|
value={settings.theme}
|
||||||
onChange={(v) => patch({ theme: v as Theme })}
|
onChange={(v) => patch({ theme: v as Theme })}
|
||||||
options={[
|
options={[
|
||||||
@@ -88,17 +104,24 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
|
icon,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
icon: React.ReactNode
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<section className="mb-8">
|
<section className="mb-7">
|
||||||
<h2 className="text-xs uppercase tracking-wider text-muted font-semibold mb-3">
|
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -119,9 +142,16 @@ function ToggleRow({
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
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="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>}
|
{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} />
|
||||||
@@ -143,15 +173,17 @@ function SelectRow({
|
|||||||
options: { value: string; label: string }[]
|
options: { value: string; label: string }[]
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
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="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>}
|
{hint && <div className="text-xs text-muted mt-0.5">{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"
|
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) => (
|
{options.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
|
|||||||
Reference in New Issue
Block a user