Time tracker app
This commit is contained in:
481
desktop/src/renderer/pages/DashboardPage.tsx
Normal file
481
desktop/src/renderer/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
298
desktop/src/renderer/pages/GamesPage.tsx
Normal file
298
desktop/src/renderer/pages/GamesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
desktop/src/renderer/pages/LoginPage.tsx
Normal file
183
desktop/src/renderer/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal file
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user