Time tracker app
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user