Files
game-marathon/desktop/src/renderer/pages/DashboardPage.tsx
2026-01-10 08:48:52 +07:00

482 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}