Time tracker app

This commit is contained in:
2026-01-10 08:24:55 +07:00
parent 3256c40841
commit b6eecc4483
46 changed files with 11368 additions and 2 deletions

View File

@@ -0,0 +1,481 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Clock, Gamepad2, Plus, Trophy, Target, Loader2, ChevronDown, Timer, Play, Square } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { useAuthStore } from '../store/auth'
import { useMarathonStore } from '../store/marathon'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
function formatMinutes(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}ч ${mins}м`
}
return `${mins}м`
}
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'text-green-400'
case 'medium': return 'text-yellow-400'
case 'hard': return 'text-red-400'
default: return 'text-gray-400'
}
}
function getDifficultyLabel(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'Легкий'
case 'medium': return 'Средний'
case 'hard': return 'Сложный'
default: return difficulty
}
}
export function DashboardPage() {
const { user } = useAuthStore()
const { trackedGames, stats, currentGame, loadTrackedGames, updateStats } = useTrackingStore()
const {
marathons,
selectedMarathonId,
currentAssignment,
isLoading,
loadMarathons,
selectMarathon,
syncTime
} = useMarathonStore()
// Monitoring state
const [isMonitoring, setIsMonitoring] = useState(false)
const [localSessionSeconds, setLocalSessionSeconds] = useState(0)
// Refs for time tracking sync
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null)
const lastSyncedMinutesRef = useRef<number>(0)
const sessionStartRef = useRef<number | null>(null)
// Check if we should track time: any tracked game is running + active assignment exists
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
// Sync time to server
const doSyncTime = useCallback(async () => {
if (!currentAssignment || !isTrackingAssignment) {
return
}
// Calculate total minutes: previous tracked + current session
const sessionDuration = sessionStartRef.current
? Math.floor((Date.now() - sessionStartRef.current) / 60000)
: 0
const totalMinutes = currentAssignment.tracked_time_minutes + sessionDuration
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${currentAssignment.id}`)
await syncTime(totalMinutes)
lastSyncedMinutesRef.current = totalMinutes
}
}, [currentAssignment, isTrackingAssignment, syncTime])
useEffect(() => {
loadTrackedGames()
loadMarathons()
// Load monitoring status
window.electronAPI.getMonitoringStatus().then(setIsMonitoring)
// Subscribe to tracking updates
const unsubscribe = window.electronAPI.onTrackingUpdate((newStats) => {
updateStats(newStats)
})
// Subscribe to game started event
const unsubGameStarted = window.electronAPI.onGameStarted((gameName, _gameId) => {
console.log(`[Game] Started: ${gameName}`)
sessionStartRef.current = Date.now()
setLocalSessionSeconds(0)
})
// Subscribe to game stopped event
const unsubGameStopped = window.electronAPI.onGameStopped((gameName, _duration) => {
console.log(`[Game] Stopped: ${gameName}`)
sessionStartRef.current = null
setLocalSessionSeconds(0)
})
// Get initial stats
window.electronAPI.getTrackingStats().then(updateStats)
return () => {
unsubscribe()
unsubGameStarted()
unsubGameStopped()
}
}, [loadTrackedGames, loadMarathons, updateStats])
// Setup sync interval and local timer when tracking
useEffect(() => {
let localTimerInterval: NodeJS.Timeout | null = null
if (isTrackingAssignment) {
// Start session if not already started
if (!sessionStartRef.current) {
sessionStartRef.current = Date.now()
}
// Sync immediately when game starts
doSyncTime()
// Setup periodic sync every 60 seconds
syncIntervalRef.current = setInterval(() => {
doSyncTime()
}, 60000)
// Update local timer every second for UI
localTimerInterval = setInterval(() => {
if (sessionStartRef.current) {
setLocalSessionSeconds(Math.floor((Date.now() - sessionStartRef.current) / 1000))
}
}, 1000)
} else {
// Do final sync when game stops
if (syncIntervalRef.current) {
doSyncTime()
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
sessionStartRef.current = null
}
setLocalSessionSeconds(0)
}
return () => {
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
}
if (localTimerInterval) {
clearInterval(localTimerInterval)
}
}
}, [isTrackingAssignment, doSyncTime])
// Toggle monitoring
const toggleMonitoring = async () => {
if (isMonitoring) {
await window.electronAPI.stopMonitoring()
setIsMonitoring(false)
} else {
await window.electronAPI.startMonitoring()
setIsMonitoring(true)
}
}
const todayTime = stats?.totalTimeToday || 0
const weekTime = stats?.totalTimeWeek || 0
const selectedMarathon = marathons.find(m => m.id === selectedMarathonId)
const renderCurrentChallenge = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
)
}
if (marathons.length === 0) {
return (
<p className="text-gray-400 text-sm">
Нет активных марафонов. Присоединитесь к марафону на сайте.
</p>
)
}
if (!currentAssignment) {
return (
<p className="text-gray-400 text-sm">
Нет активного задания. Крутите колесо на сайте!
</p>
)
}
const assignment = currentAssignment
// Playthrough assignment
if (assignment.is_playthrough && assignment.playthrough_info) {
// Use localSessionSeconds for live display (updates every second)
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
const totalSeconds = (assignment.tracked_time_minutes * 60) + sessionSeconds
const totalMinutes = Math.floor(totalSeconds / 60)
const trackedHours = totalMinutes / 60
const estimatedPoints = Math.floor(trackedHours * 30)
// Format with seconds when actively tracking
const formatLiveTime = () => {
if (isTrackingAssignment && sessionSeconds > 0) {
const hours = Math.floor(totalSeconds / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
if (hours > 0) {
return `${hours}ч ${mins}м ${secs}с`
}
return `${mins}м ${secs}с`
}
return formatMinutes(totalMinutes)
}
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-white">
Прохождение: {assignment.game.title}
</h3>
{isTrackingAssignment && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs text-green-400">
<div className="live-indicator" />
Идёт запись
</span>
)}
</div>
{assignment.playthrough_info.description && (
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{assignment.playthrough_info.description}
</p>
)}
<div className="flex items-center gap-3 text-xs flex-wrap">
{totalSeconds > 0 || isTrackingAssignment ? (
<>
<span className="flex items-center gap-1 text-neon-400">
<Timer className="w-3 h-3" />
{formatLiveTime()}
</span>
<span className="text-neon-400 font-medium">
~{estimatedPoints} очков
</span>
</>
) : (
<span className="text-gray-500">
Базово: {assignment.playthrough_info.points} очков
</span>
)}
</div>
</div>
</div>
</div>
)
}
// Challenge assignment
if (assignment.challenge) {
const challenge = assignment.challenge
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="font-medium text-white mb-1">{challenge.title}</h3>
<p className="text-xs text-gray-500 mb-1">{challenge.game.title}</p>
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{challenge.description}
</p>
<div className="flex items-center gap-3 text-xs">
<span className={getDifficultyColor(challenge.difficulty)}>
[{getDifficultyLabel(challenge.difficulty)}]
</span>
<span className="text-neon-400 font-medium">
+{challenge.points} очков
</span>
{challenge.estimated_time && (
<span className="text-gray-500">
~{challenge.estimated_time} мин
</span>
)}
</div>
</div>
</div>
</div>
)
}
return (
<p className="text-gray-400 text-sm">
Задание загружается...
</p>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-display font-bold text-white">
Привет, {user?.nickname || 'Игрок'}!
</h1>
<p className="text-sm text-gray-400">
{isMonitoring ? (currentGame ? `Играет: ${currentGame}` : 'Мониторинг активен') : 'Мониторинг выключен'}
</p>
</div>
<div className="flex items-center gap-2">
{currentGame && isMonitoring && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/10 border border-green-500/30 rounded-full">
<div className="live-indicator" />
<span className="text-xs text-green-400 font-medium truncate max-w-[100px]">{currentGame}</span>
</div>
)}
<button
onClick={toggleMonitoring}
className={`p-2 rounded-lg transition-colors ${
isMonitoring
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
title={isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг'}
>
{isMonitoring ? <Square className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 gap-3">
<GlassCard variant="neon" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="text-xs text-gray-400">Сегодня</p>
<p className="text-lg font-bold text-white">{formatTime(todayTime)}</p>
</div>
</div>
</GlassCard>
<GlassCard variant="default" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Trophy className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="text-xs text-gray-400">За неделю</p>
<p className="text-lg font-bold text-white">{formatTime(weekTime)}</p>
</div>
</div>
</GlassCard>
</div>
{/* Current challenge */}
<GlassCard variant="dark" className="border border-neon-500/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-neon-500" />
<h2 className="font-semibold text-white">Текущий челлендж</h2>
</div>
{/* Marathon selector */}
{marathons.length > 1 && (
<div className="relative">
<select
value={selectedMarathonId || ''}
onChange={(e) => selectMarathon(Number(e.target.value))}
className="appearance-none bg-dark-800 border border-dark-600 rounded-lg px-3 py-1.5 pr-8 text-xs text-gray-300 focus:outline-none focus:border-neon-500 cursor-pointer"
>
{marathons.map(m => (
<option key={m.id} value={m.id}>
{m.title.length > 30 ? m.title.substring(0, 30) + '...' : m.title}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
</div>
)}
</div>
{/* Marathon title for single marathon */}
{marathons.length === 1 && selectedMarathon && (
<p className="text-xs text-gray-500 mb-2">{selectedMarathon.title}</p>
)}
{renderCurrentChallenge()}
</GlassCard>
{/* Tracked games */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-white flex items-center gap-2">
<Gamepad2 className="w-5 h-5 text-neon-500" />
Отслеживаемые игры
</h2>
<Link to="/games">
<NeonButton variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />}>
Добавить
</NeonButton>
</Link>
</div>
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-8">
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 text-sm mb-4">
Нет отслеживаемых игр
</p>
<Link to="/games">
<NeonButton variant="secondary" size="sm">
Добавить игру
</NeonButton>
</Link>
</GlassCard>
) : (
<div className="grid grid-cols-2 gap-2">
{trackedGames.slice(0, 4).map((game) => (
<GlassCard
key={game.id}
variant="default"
hover
className="p-3"
>
<div className="flex items-center gap-2 mb-2">
{currentGame === game.name && <div className="live-indicator" />}
<p className="text-sm font-medium text-white truncate flex-1">
{game.name}
</p>
</div>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)}
</p>
</GlassCard>
))}
</div>
)}
{trackedGames.length > 4 && (
<Link to="/games" className="block mt-2">
<NeonButton variant="ghost" size="sm" className="w-full">
Показать все ({trackedGames.length})
</NeonButton>
</Link>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useState } from 'react'
import { Gamepad2, Plus, Trash2, Search, FolderOpen, Cpu, RefreshCw, Loader2 } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { TrackedProcess } from '@shared/types'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
// System processes to filter out
const SYSTEM_PROCESSES = new Set([
'svchost', 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
'dwm', 'explorer', 'taskhost', 'conhost', 'spoolsv', 'searchhost',
'runtimebroker', 'sihost', 'fontdrvhost', 'ctfmon', 'dllhost',
'securityhealthservice', 'searchindexer', 'audiodg', 'wudfhost',
'system', 'registry', 'idle', 'memory compression', 'ntoskrnl',
'shellexperiencehost', 'startmenuexperiencehost', 'applicationframehost',
'systemsettings', 'textinputhost', 'searchui', 'cortana', 'lockapp',
'windowsinternal', 'taskhostw', 'wmiprvse', 'msiexec', 'trustedinstaller',
'tiworker', 'smartscreen', 'securityhealthsystray', 'sgrmbroker',
'gamebarpresencewriter', 'gamebar', 'gamebarftserver',
'microsoftedge', 'msedge', 'chrome', 'firefox', 'opera', 'brave',
'discord', 'slack', 'teams', 'zoom', 'skype',
'powershell', 'cmd', 'windowsterminal', 'code', 'devenv',
'node', 'npm', 'electron', 'vite'
])
export function GamesPage() {
const { trackedGames, currentGame, loadTrackedGames, addGame, removeGame } = useTrackingStore()
const [showAddModal, setShowAddModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [addMode, setAddMode] = useState<'process' | 'manual'>('process')
const [manualGame, setManualGame] = useState({ name: '', executableName: '' })
const [processes, setProcesses] = useState<TrackedProcess[]>([])
const [isLoadingProcesses, setIsLoadingProcesses] = useState(false)
useEffect(() => {
loadTrackedGames()
}, [loadTrackedGames])
const loadProcesses = async () => {
setIsLoadingProcesses(true)
try {
const procs = await window.electronAPI.getRunningProcesses()
// Filter out system processes and already tracked games
const filtered = procs.filter(p => {
const name = p.name.toLowerCase().replace('.exe', '')
return !SYSTEM_PROCESSES.has(name) &&
!trackedGames.some(tg =>
tg.executableName.toLowerCase().replace('.exe', '') === name
)
})
setProcesses(filtered)
} catch (error) {
console.error('Failed to load processes:', error)
} finally {
setIsLoadingProcesses(false)
}
}
useEffect(() => {
if (showAddModal && addMode === 'process') {
loadProcesses()
}
}, [showAddModal, addMode])
const filteredProcesses = processes.filter(
(proc) =>
proc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(proc.windowTitle && proc.windowTitle.toLowerCase().includes(searchQuery.toLowerCase()))
)
const handleAddProcess = async (process: TrackedProcess) => {
const name = process.windowTitle || process.displayName || process.name.replace('.exe', '')
await addGame({
id: `proc_${Date.now()}`,
name: name,
executableName: process.name,
executablePath: process.executablePath,
})
setShowAddModal(false)
setSearchQuery('')
}
const handleAddManualGame = async () => {
if (!manualGame.name || !manualGame.executableName) return
await addGame({
id: `manual_${Date.now()}`,
name: manualGame.name,
executableName: manualGame.executableName,
})
setShowAddModal(false)
setManualGame({ name: '', executableName: '' })
}
const handleRemoveGame = async (gameId: string) => {
if (confirm('Удалить игру из отслеживания?')) {
await removeGame(gameId)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-white flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-neon-500" />
Игры
</h1>
<NeonButton
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowAddModal(true)}
>
Добавить
</NeonButton>
</div>
{/* Games list */}
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-12">
<Gamepad2 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Нет игр</h3>
<p className="text-gray-400 text-sm mb-4">
Добавьте игры для отслеживания времени
</p>
<NeonButton onClick={() => setShowAddModal(true)}>
Добавить игру
</NeonButton>
</GlassCard>
) : (
<div className="space-y-2">
{trackedGames.map((game) => (
<GlassCard
key={game.id}
variant="default"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3 min-w-0">
{currentGame === game.name && <div className="live-indicator flex-shrink-0" />}
<div className="min-w-0">
<p className="font-medium text-white truncate">{game.name}</p>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)} наиграно
{game.steamAppId && ' • Steam'}
</p>
</div>
</div>
<button
onClick={() => handleRemoveGame(game.id)}
className="p-2 text-gray-400 hover:text-red-400 transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</GlassCard>
))}
</div>
)}
{/* Add game modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/80 backdrop-blur-sm">
<GlassCard variant="dark" className="w-full max-w-sm mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Добавить игру</h2>
<button
onClick={() => setShowAddModal(false)}
className="p-1 text-gray-400 hover:text-white"
>
</button>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setAddMode('process')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'process'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<Cpu className="w-3.5 h-3.5" />
Процессы
</button>
<button
onClick={() => setAddMode('manual')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'manual'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<FolderOpen className="w-3.5 h-3.5" />
Вручную
</button>
</div>
{addMode === 'process' && (
<>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Поиск процесса..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
icon={<Search className="w-4 h-4" />}
/>
</div>
<button
onClick={loadProcesses}
disabled={isLoadingProcesses}
className="p-2.5 bg-dark-700 border border-dark-600 rounded-lg text-gray-400 hover:text-white hover:border-neon-500/50 transition-colors disabled:opacity-50"
title="Обновить список"
>
<RefreshCw className={`w-4 h-4 ${isLoadingProcesses ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Запустите игру и нажмите обновить
</p>
<div className="mt-3 space-y-2 overflow-y-auto max-h-52">
{isLoadingProcesses ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : filteredProcesses.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-4">
{processes.length === 0 ? 'Нет подходящих процессов' : 'Ничего не найдено'}
</p>
) : (
filteredProcesses.slice(0, 20).map((proc) => (
<button
key={proc.id}
onClick={() => handleAddProcess(proc)}
className="w-full flex items-start gap-3 p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-left"
>
<Cpu className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">
{proc.windowTitle || proc.displayName || proc.name}
</p>
<p className="text-xs text-gray-500 truncate">
{proc.name}
</p>
</div>
</button>
))
)}
</div>
</>
)}
{addMode === 'manual' && (
<div className="space-y-4">
<Input
label="Название игры"
placeholder="Например: Elden Ring"
value={manualGame.name}
onChange={(e) => setManualGame({ ...manualGame, name: e.target.value })}
/>
<Input
label="Имя процесса (exe)"
placeholder="Например: eldenring.exe"
value={manualGame.executableName}
onChange={(e) => setManualGame({ ...manualGame, executableName: e.target.value })}
/>
<NeonButton
className="w-full"
onClick={handleAddManualGame}
disabled={!manualGame.name || !manualGame.executableName}
>
Добавить
</NeonButton>
</div>
)}
</GlassCard>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Gamepad2, User, Lock, X, Minus, Shield, ArrowLeft } from 'lucide-react'
import { useAuthStore } from '../store/auth'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
export function LoginPage() {
const navigate = useNavigate()
const { login, verify2fa, isLoading, error, clearError, requires2fa, reset2fa } = useAuthStore()
const [formData, setFormData] = useState({
login: '',
password: '',
})
const [twoFactorCode, setTwoFactorCode] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await login(formData.login, formData.password)
if (success) {
navigate('/')
}
}
const handle2faSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await verify2fa(twoFactorCode)
if (success) {
navigate('/')
}
}
const handleBack = () => {
reset2fa()
setTwoFactorCode('')
}
return (
<div className="min-h-screen bg-dark-900 flex flex-col">
{/* Custom title bar */}
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4 text-neon-500" />
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
</div>
<div className="flex items-center">
<button
onClick={() => window.electronAPI.minimizeToTray()}
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => window.electronAPI.quitApp()}
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-neon-500/10 border border-neon-500/30 mb-4">
{requires2fa ? (
<Shield className="w-8 h-8 text-neon-500" />
) : (
<Gamepad2 className="w-8 h-8 text-neon-500" />
)}
</div>
<h1 className="text-2xl font-display font-bold text-white mb-2">
{requires2fa ? 'Подтверждение' : 'Game Marathon'}
</h1>
<p className="text-gray-400 text-sm">
{requires2fa
? 'Введите код из Telegram'
: 'Войдите в свой аккаунт'}
</p>
</div>
{requires2fa ? (
/* 2FA Form */
<form onSubmit={handle2faSubmit} className="space-y-4">
<Input
label="Код подтверждения"
type="text"
value={twoFactorCode}
onChange={(e) => {
setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))
clearError()
}}
icon={<Shield className="w-5 h-5" />}
placeholder="000000"
maxLength={6}
required
autoFocus
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFactorCode.length !== 6}
>
Подтвердить
</NeonButton>
<button
type="button"
onClick={handleBack}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white transition-colors py-2"
>
<ArrowLeft className="w-4 h-4" />
Назад
</button>
</form>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Логин"
type="text"
value={formData.login}
onChange={(e) => {
setFormData({ ...formData, login: e.target.value })
clearError()
}}
icon={<User className="w-5 h-5" />}
placeholder="Введите логин"
required
/>
<Input
label="Пароль"
type="password"
value={formData.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value })
clearError()
}}
icon={<Lock className="w-5 h-5" />}
placeholder="Введите пароль"
required
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
>
Войти
</NeonButton>
</form>
)}
{/* Footer */}
{!requires2fa && (
<p className="text-center text-gray-500 text-xs mt-6">
Нет аккаунта? Зарегистрируйтесь на сайте
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { Settings, Power, Monitor, Clock, Globe, LogOut, Download, RefreshCw, Check, AlertCircle } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/auth'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { AppSettings } from '@shared/types'
export function SettingsPage() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [settings, setSettings] = useState<AppSettings | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'not-available' | 'error'>('idle')
const [updateVersion, setUpdateVersion] = useState('')
const [updateError, setUpdateError] = useState('')
useEffect(() => {
window.electronAPI.getSettings().then(setSettings)
window.electronAPI.getAppVersion().then(setAppVersion)
}, [])
const handleCheckForUpdates = async () => {
setUpdateStatus('checking')
setUpdateError('')
try {
const result = await window.electronAPI.checkForUpdates()
if (result.error) {
setUpdateStatus('error')
setUpdateError(result.error)
} else if (result.available) {
setUpdateStatus('available')
setUpdateVersion(result.version || '')
} else {
setUpdateStatus('not-available')
}
} catch (err) {
setUpdateStatus('error')
setUpdateError('Ошибка проверки')
}
}
const handleInstallUpdate = () => {
window.electronAPI.installUpdate()
}
const handleToggle = async (key: keyof AppSettings, value: boolean) => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ [key]: value })
setSettings({ ...settings, [key]: value })
} finally {
setIsSaving(false)
}
}
const handleApiUrlChange = async (url: string) => {
if (!settings) return
setSettings({ ...settings, apiUrl: url })
}
const handleApiUrlSave = async () => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ apiUrl: settings.apiUrl })
} finally {
setIsSaving(false)
}
}
const handleLogout = async () => {
await logout()
navigate('/login')
}
if (!settings) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Settings className="w-6 h-6 text-neon-500" />
<h1 className="text-xl font-display font-bold text-white">Настройки</h1>
</div>
{/* User info */}
<GlassCard variant="neon" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neon-500/20 flex items-center justify-center">
<span className="text-lg font-bold text-neon-400">
{user?.nickname?.charAt(0).toUpperCase() || 'U'}
</span>
</div>
<div>
<p className="font-medium text-white">{user?.nickname}</p>
<p className="text-xs text-gray-400">@{user?.login}</p>
</div>
</div>
<NeonButton variant="ghost" size="sm" icon={<LogOut className="w-4 h-4" />} onClick={handleLogout}>
Выйти
</NeonButton>
</GlassCard>
{/* Settings */}
<div className="space-y-2">
{/* Auto-launch */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Power className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="font-medium text-white">Автозапуск</p>
<p className="text-xs text-gray-400">Запускать при старте Windows</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.autoLaunch}
onChange={(e) => handleToggle('autoLaunch', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Minimize to tray */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Monitor className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="font-medium text-white">Сворачивать в трей</p>
<p className="text-xs text-gray-400">При закрытии скрывать в трей</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.minimizeToTray}
onChange={(e) => handleToggle('minimizeToTray', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Tracking interval */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-pink-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-pink-500" />
</div>
<div>
<p className="font-medium text-white">Интервал проверки</p>
<p className="text-xs text-gray-400">Как часто проверять процессы</p>
</div>
</div>
<select
value={settings.trackingInterval}
onChange={(e) => {
const value = Number(e.target.value)
setSettings({ ...settings, trackingInterval: value })
window.electronAPI.saveSettings({ trackingInterval: value })
}}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-4 py-2 text-white"
>
<option value={3000}>3 секунды</option>
<option value={5000}>5 секунд</option>
<option value={10000}>10 секунд</option>
<option value={30000}>30 секунд</option>
</select>
</GlassCard>
{/* Updates */}
<GlassCard variant="dark">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Download className="w-5 h-5 text-green-500" />
</div>
<div>
<p className="font-medium text-white">Обновления</p>
<p className="text-xs text-gray-400">
{updateStatus === 'checking' && 'Проверка...'}
{updateStatus === 'available' && `Доступна v${updateVersion}`}
{updateStatus === 'not-available' && 'Актуальная версия'}
{updateStatus === 'error' && (updateError || 'Ошибка')}
{updateStatus === 'idle' && `Текущая версия: v${appVersion}`}
</p>
</div>
</div>
{updateStatus === 'available' ? (
<NeonButton size="sm" onClick={handleInstallUpdate}>
Установить
</NeonButton>
) : (
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="p-2 rounded-lg bg-dark-700 text-gray-400 hover:text-white hover:bg-dark-600 transition-colors disabled:opacity-50"
>
{updateStatus === 'checking' ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : updateStatus === 'not-available' ? (
<Check className="w-5 h-5 text-green-500" />
) : updateStatus === 'error' ? (
<AlertCircle className="w-5 h-5 text-red-500" />
) : (
<RefreshCw className="w-5 h-5" />
)}
</button>
)}
</div>
</GlassCard>
{/* API URL (for developers) */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-gray-500/10 flex items-center justify-center">
<Globe className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="font-medium text-white">API URL</p>
<p className="text-xs text-gray-400">Для разработки</p>
</div>
</div>
<div className="flex gap-2">
<Input
value={settings.apiUrl}
onChange={(e) => handleApiUrlChange(e.target.value)}
placeholder="http://localhost:8000/api/v1"
className="flex-1"
/>
<NeonButton
variant="secondary"
size="sm"
onClick={handleApiUrlSave}
disabled={isSaving}
>
Сохранить
</NeonButton>
</div>
</GlassCard>
</div>
{/* Version */}
<p className="text-center text-gray-500 text-xs pt-4">
Game Marathon Tracker v{appVersion || '...'}
</p>
</div>
)
}