Compare commits

...

2 Commits

Author SHA1 Message Date
2b6f2888ee Fix site 2026-01-10 08:48:53 +07:00
b6eecc4483 Time tracker app 2026-01-10 08:48:52 +07:00
49 changed files with 11421 additions and 5 deletions

View File

@@ -0,0 +1,30 @@
"""Add tracked_time_minutes to assignments
Revision ID: 029_add_tracked_time
Revises: 028_add_promo_codes
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '029_add_tracked_time'
down_revision: Union[str, None] = '028_add_promo_codes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add tracked_time_minutes column to assignments table
op.add_column(
'assignments',
sa.Column('tracked_time_minutes', sa.Integer(), nullable=False, server_default='0')
)
def downgrade() -> None:
op.drop_column('assignments', 'tracked_time_minutes')

View File

@@ -15,6 +15,7 @@ from app.models import (
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse, GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
TrackTimeRequest,
) )
from app.schemas.game import PlaythroughInfo from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService from app.services.points import PointsService
@@ -589,6 +590,13 @@ async def complete_assignment(
if assignment.is_playthrough: if assignment.is_playthrough:
game = assignment.game game = assignment.game
marathon_id = game.marathon_id marathon_id = game.marathon_id
# If tracked time exists (from desktop app), calculate points as hours * 30
# Otherwise use admin-set playthrough_points
if assignment.tracked_time_minutes > 0:
hours = assignment.tracked_time_minutes / 60
base_playthrough_points = int(hours * 30)
else:
base_playthrough_points = game.playthrough_points base_playthrough_points = game.playthrough_points
# Calculate BASE bonus points from completed bonus assignments (before multiplier) # Calculate BASE bonus points from completed bonus assignments (before multiplier)
@@ -850,6 +858,37 @@ async def complete_assignment(
) )
@router.patch("/assignments/{assignment_id}/track-time", response_model=MessageResponse)
async def track_assignment_time(
assignment_id: int,
data: TrackTimeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Update tracked time for an assignment (from desktop app)"""
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.participant))
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Update tracked time (replace with new value)
assignment.tracked_time_minutes = max(0, data.minutes)
await db.commit()
return MessageResponse(message=f"Tracked time updated to {data.minutes} minutes")
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment""" """Drop current assignment"""

View File

@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173", # Desktop app dev
"http://127.0.0.1:5173",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -32,6 +32,7 @@ class Assignment(Base):
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True) proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0) points_earned: Mapped[int] = mapped_column(Integer, default=0)
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True) streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
tracked_time_minutes: Mapped[int] = mapped_column(Integer, default=0) # Time tracked by desktop app
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -52,6 +52,7 @@ from app.schemas.assignment import (
CompleteBonusAssignment, CompleteBonusAssignment,
BonusCompleteResult, BonusCompleteResult,
AvailableGamesCount, AvailableGamesCount,
TrackTimeRequest,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,

View File

@@ -52,6 +52,7 @@ class AssignmentResponse(BaseModel):
proof_comment: str | None = None proof_comment: str | None = None
points_earned: int points_earned: int
streak_at_completion: int | None = None streak_at_completion: int | None = None
tracked_time_minutes: int = 0 # Time tracked by desktop app
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped drop_penalty: int = 0 # Calculated penalty if dropped
@@ -62,6 +63,11 @@ class AssignmentResponse(BaseModel):
from_attributes = True from_attributes = True
class TrackTimeRequest(BaseModel):
"""Request to update tracked time for an assignment"""
minutes: int # Total minutes tracked (replaces previous value)
class SpinResult(BaseModel): class SpinResult(BaseModel):
assignment_id: int assignment_id: int
game: GameResponse game: GameResponse

28
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
release/
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Electron
*.asar

6893
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
desktop/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "game-marathon-tracker",
"version": "1.0.0",
"description": "Desktop app for tracking game time in Game Marathon",
"main": "dist/main/main/index.js",
"author": "Game Marathon",
"license": "MIT",
"scripts": {
"dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build && node -e \"require('fs').copyFileSync('src/renderer/splash.html', 'dist/renderer/splash.html'); require('fs').copyFileSync('src/renderer/logo.jpg', 'dist/renderer/logo.jpg')\"",
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"auto-launch": "^5.0.6",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.7.3",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1",
"vdf-parser": "^1.0.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/auto-launch": "^5.0.5",
"@types/node": "^20.11.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.gamemarathon.tracker",
"productName": "Game Marathon Tracker",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"resources/**/*"
],
"win": {
"target": [
"nsis",
"portable"
],
"icon": "resources/icon.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": {
"provider": "github",
"owner": "Oronemu",
"repo": "marathon_tracker"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
# Resources placeholder
# Add icon.ico and tray-icon.png here

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
desktop/resources/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,145 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import type { StoreType } from './storeTypes'
interface ApiResponse<T = unknown> {
data: T
status: number
}
interface ApiError {
status: number
message: string
detail?: unknown
}
export class ApiClient {
private store: StoreType
constructor(store: StoreType) {
this.store = store
}
private getBaseUrl(): string {
return this.store.get('settings').apiUrl || 'https://marathon.animeenigma.ru/api/v1'
}
private getToken(): string | null {
return this.store.get('token')
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
endpoint: string,
data?: unknown
): Promise<ApiResponse<T>> {
const baseUrl = this.getBaseUrl().replace(/\/$/, '') // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
const fullUrl = `${baseUrl}${cleanEndpoint}`
const url = new URL(fullUrl)
const token = this.getToken()
const isHttps = url.protocol === 'https:'
const httpModule = isHttps ? https : http
const body = data ? JSON.stringify(data) : undefined
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
},
}
console.log(`[ApiClient] ${method} ${url.href}`)
return new Promise((resolve, reject) => {
const req = httpModule.request(options, (res) => {
let responseData = ''
res.on('data', (chunk) => {
responseData += chunk
})
res.on('end', () => {
console.log(`[ApiClient] Response status: ${res.statusCode}`)
console.log(`[ApiClient] Response body: ${responseData.substring(0, 500)}`)
try {
const parsed = responseData ? JSON.parse(responseData) : {}
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({
data: parsed as T,
status: res.statusCode,
})
} else {
const error: ApiError = {
status: res.statusCode || 500,
message: parsed.detail || 'Request failed',
detail: parsed.detail,
}
reject(error)
}
} catch (e) {
console.error('[ApiClient] Parse error:', e)
console.error('[ApiClient] Raw response:', responseData)
reject({
status: res.statusCode || 500,
message: 'Failed to parse response',
})
}
})
})
req.on('error', (e) => {
console.error('[ApiClient] Request error:', e)
reject({
status: 0,
message: e.message || 'Network error',
})
})
req.setTimeout(30000, () => {
console.error('[ApiClient] Request timeout')
req.destroy()
reject({
status: 0,
message: 'Request timeout',
})
})
if (body) {
req.write(body)
}
req.end()
})
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('GET', endpoint)
}
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('POST', endpoint, data)
}
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PUT', endpoint, data)
}
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', endpoint, data)
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', endpoint)
}
}

View File

@@ -0,0 +1,42 @@
import AutoLaunch from 'auto-launch'
import { app } from 'electron'
let autoLauncher: AutoLaunch | null = null
export async function setupAutoLaunch(enabled: boolean): Promise<void> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
const isEnabled = await autoLauncher.isEnabled()
if (enabled && !isEnabled) {
await autoLauncher.enable()
console.log('Auto-launch enabled')
} else if (!enabled && isEnabled) {
await autoLauncher.disable()
console.log('Auto-launch disabled')
}
} catch (error) {
console.error('Failed to setup auto-launch:', error)
}
}
export async function isAutoLaunchEnabled(): Promise<boolean> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
return await autoLauncher.isEnabled()
} catch {
return false
}
}

181
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,181 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import * as path from 'path'
import Store from 'electron-store'
import { setupTray, destroyTray } from './tray'
import { setupAutoLaunch } from './autolaunch'
import { setupIpcHandlers } from './ipc'
import { ProcessTracker } from './tracking/processTracker'
import { createSplashWindow, setupAutoUpdater, setupUpdateIpcHandlers } from './updater'
import type { StoreType } from './storeTypes'
import './storeTypes' // Import for global type declarations
// Initialize electron store
const store = new Store({
defaults: {
settings: {
autoLaunch: false,
minimizeToTray: true,
trackingInterval: 5000,
apiUrl: 'https://marathon.animeenigma.ru/api/v1',
theme: 'dark',
},
token: null,
trackedGames: {},
trackingData: {},
},
}) as StoreType
let mainWindow: BrowserWindow | null = null
let processTracker: ProcessTracker | null = null
let isMonitoringEnabled = false
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
function createWindow() {
// __dirname is dist/main/main/ in both dev and prod
const iconPath = path.join(__dirname, '../../../resources/icon.ico')
mainWindow = new BrowserWindow({
width: 450,
height: 750,
resizable: false,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0d0e14',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
icon: iconPath,
})
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
}
// Handle close to tray
mainWindow.on('close', (event) => {
const settings = store.get('settings')
if (settings.minimizeToTray && !app.isQuitting) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Setup tray icon
setupTray(mainWindow, store)
return mainWindow
}
app.whenReady().then(async () => {
// Setup IPC handlers
setupIpcHandlers(store, () => mainWindow)
setupUpdateIpcHandlers()
// Show splash screen and check for updates
createSplashWindow()
setupAutoUpdater(async () => {
// This runs after update check is complete (or skipped)
// Create the main window
createWindow()
// Setup auto-launch
const settings = store.get('settings')
await setupAutoLaunch(settings.autoLaunch)
// Initialize process tracker (but don't start automatically)
processTracker = new ProcessTracker(
store,
(stats) => {
mainWindow?.webContents.send('tracking-update', stats)
},
(event) => {
// Game started
mainWindow?.webContents.send('game-started', event.gameName, event.gameId)
},
(event) => {
// Game stopped
mainWindow?.webContents.send('game-stopped', event.gameName, event.duration || 0)
}
)
// Don't start automatically - user will start via button
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// Don't quit on Windows if minimize to tray is enabled
const settings = store.get('settings')
if (!settings.minimizeToTray) {
app.quit()
}
}
})
app.on('before-quit', () => {
app.isQuitting = true
processTracker?.stop()
destroyTray()
})
// Handle IPC for window controls
ipcMain.on('minimize-to-tray', () => {
mainWindow?.hide()
})
ipcMain.on('quit-app', () => {
app.isQuitting = true
app.quit()
})
// Monitoring control
ipcMain.handle('start-monitoring', () => {
if (!isMonitoringEnabled && processTracker) {
processTracker.start()
isMonitoringEnabled = true
console.log('Monitoring started')
}
return isMonitoringEnabled
})
ipcMain.handle('stop-monitoring', () => {
if (isMonitoringEnabled && processTracker) {
processTracker.stop()
isMonitoringEnabled = false
console.log('Monitoring stopped')
}
return isMonitoringEnabled
})
ipcMain.handle('get-monitoring-status', () => {
return isMonitoringEnabled
})
// Export for use in other modules
export { store, mainWindow }

174
desktop/src/main/ipc.ts Normal file
View File

@@ -0,0 +1,174 @@
import { ipcMain, BrowserWindow } from 'electron'
import { setupAutoLaunch } from './autolaunch'
import { getRunningProcesses, getForegroundWindow } from './tracking/processTracker'
import { getSteamGames, getSteamPath } from './tracking/steamIntegration'
import { getTrackingStats, getTrackedGames, addTrackedGame, removeTrackedGame } from './tracking/timeStorage'
import { ApiClient } from './apiClient'
import type { TrackedGame, AppSettings, User, LoginResponse } from '../shared/types'
import type { StoreType } from './storeTypes'
export function setupIpcHandlers(
store: StoreType,
getMainWindow: () => BrowserWindow | null
) {
const apiClient = new ApiClient(store)
// Settings handlers
ipcMain.handle('get-settings', () => {
return store.get('settings')
})
ipcMain.handle('save-settings', async (_event, settings: Partial<AppSettings>) => {
const currentSettings = store.get('settings')
const newSettings = { ...currentSettings, ...settings }
store.set('settings', newSettings)
// Handle auto-launch setting change
if (settings.autoLaunch !== undefined) {
await setupAutoLaunch(settings.autoLaunch)
}
return newSettings
})
// Auth handlers
ipcMain.handle('get-token', () => {
return store.get('token')
})
ipcMain.handle('save-token', (_event, token: string) => {
store.set('token', token)
})
ipcMain.handle('clear-token', () => {
store.set('token', null)
})
// Process tracking handlers
ipcMain.handle('get-running-processes', async () => {
return await getRunningProcesses()
})
ipcMain.handle('get-foreground-window', async () => {
return await getForegroundWindow()
})
ipcMain.handle('get-tracking-stats', () => {
return getTrackingStats(store)
})
// Steam handlers
ipcMain.handle('get-steam-games', async () => {
return await getSteamGames()
})
ipcMain.handle('get-steam-path', () => {
return getSteamPath()
})
// Tracked games handlers
ipcMain.handle('get-tracked-games', () => {
return getTrackedGames(store)
})
ipcMain.handle('add-tracked-game', (_event, game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => {
return addTrackedGame(store, game)
})
ipcMain.handle('remove-tracked-game', (_event, gameId: string) => {
removeTrackedGame(store, gameId)
})
// API handlers - all requests go through main process (no CORS issues)
ipcMain.handle('api-login', async (_event, login: string, password: string) => {
console.log('[API] Login attempt for:', login)
try {
const response = await apiClient.post<LoginResponse>('/auth/login', { login, password })
console.log('[API] Login response:', response.status)
// Save token if login successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] Login error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Login failed',
status: err.status
}
}
})
ipcMain.handle('api-get-me', async () => {
try {
const response = await apiClient.get<User>('/auth/me')
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Failed to get user',
status: err.status
}
}
})
ipcMain.handle('api-2fa-verify', async (_event, sessionId: number, code: string) => {
console.log('[API] 2FA verify attempt')
try {
const response = await apiClient.post<LoginResponse>(`/auth/2fa/verify?session_id=${sessionId}&code=${code}`)
console.log('[API] 2FA verify response:', response.status)
// Save token if verification successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] 2FA verify error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || '2FA verification failed',
status: err.status
}
}
})
ipcMain.handle('api-request', async (_event, method: string, endpoint: string, data?: unknown) => {
try {
let response
switch (method.toUpperCase()) {
case 'GET':
response = await apiClient.get(endpoint)
break
case 'POST':
response = await apiClient.post(endpoint, data)
break
case 'PUT':
response = await apiClient.put(endpoint, data)
break
case 'PATCH':
response = await apiClient.patch(endpoint, data)
break
case 'DELETE':
response = await apiClient.delete(endpoint)
break
default:
throw new Error(`Unknown method: ${method}`)
}
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Request failed',
status: err.status
}
}
})
}

View File

@@ -0,0 +1,28 @@
import Store from 'electron-store'
import type { AppSettings, TrackedGame } from '../shared/types'
export interface GameTrackingData {
totalTime: number
sessions: Array<{
startTime: number
endTime: number
duration: number
}>
lastPlayed: number
}
export type StoreType = Store<{
settings: AppSettings
token: string | null
trackedGames: Record<string, TrackedGame>
trackingData: Record<string, GameTrackingData>
}>
// Extend Electron App type
declare global {
namespace Electron {
interface App {
isQuitting?: boolean
}
}
}

View File

@@ -0,0 +1,284 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import type { TrackedProcess, TrackingStats, TrackedGame } from '../../shared/types'
import type { StoreType } from '../storeTypes'
import { updateGameTime, getTrackedGames } from './timeStorage'
import { updateTrayMenu } from '../tray'
const execAsync = promisify(exec)
interface ProcessInfo {
ProcessName: string
MainWindowTitle: string
Id: number
}
export async function getRunningProcesses(): Promise<TrackedProcess[]> {
try {
const { stdout } = await execAsync(
'powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.MainWindowTitle} | Select-Object ProcessName, MainWindowTitle, Id | ConvertTo-Json -Compress"',
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
)
if (!stdout.trim()) {
return []
}
let processes: ProcessInfo[]
try {
const parsed = JSON.parse(stdout)
processes = Array.isArray(parsed) ? parsed : [parsed]
} catch {
return []
}
return processes.map((proc) => ({
id: proc.Id.toString(),
name: proc.ProcessName,
displayName: proc.MainWindowTitle || proc.ProcessName,
windowTitle: proc.MainWindowTitle,
isGame: isLikelyGame(proc.ProcessName, proc.MainWindowTitle),
}))
} catch (error) {
console.error('Failed to get running processes:', error)
return []
}
}
export async function getForegroundWindow(): Promise<string | null> {
try {
// Use base64 encoded script to avoid escaping issues
const script = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class FGWindow {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@
\$hwnd = [FGWindow]::GetForegroundWindow()
\$processId = 0
[void][FGWindow]::GetWindowThreadProcessId(\$hwnd, [ref]\$processId)
\$proc = Get-Process -Id \$processId -ErrorAction SilentlyContinue
if (\$proc) { Write-Output \$proc.ProcessName }
`
// Encode script as base64
const base64Script = Buffer.from(script, 'utf16le').toString('base64')
const { stdout } = await execAsync(
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${base64Script}`,
{ encoding: 'utf8', timeout: 5000 }
)
const result = stdout.trim()
return result || null
} catch (error) {
console.error('[getForegroundWindow] Error:', error)
return null
}
}
function isLikelyGame(processName: string, windowTitle: string): boolean {
const gameIndicators = [
'game', 'steam', 'epic', 'uplay', 'origin', 'battle.net',
'unity', 'unreal', 'godot', 'ue4', 'ue5',
]
const lowerName = processName.toLowerCase()
const lowerTitle = (windowTitle || '').toLowerCase()
// Check for common game launchers/engines
for (const indicator of gameIndicators) {
if (lowerName.includes(indicator) || lowerTitle.includes(indicator)) {
return true
}
}
// Exclude common non-game processes
const nonGameProcesses = [
'explorer', 'chrome', 'firefox', 'edge', 'opera', 'brave',
'code', 'idea', 'webstorm', 'pycharm', 'rider',
'discord', 'slack', 'teams', 'zoom', 'telegram',
'spotify', 'vlc', 'foobar', 'winamp',
'notepad', 'word', 'excel', 'powerpoint', 'outlook',
'cmd', 'powershell', 'terminal', 'windowsterminal',
]
for (const nonGame of nonGameProcesses) {
if (lowerName.includes(nonGame)) {
return false
}
}
return true
}
interface GameEvent {
gameName: string
gameId: string
duration?: number
}
export class ProcessTracker {
private intervalId: NodeJS.Timeout | null = null
private currentGame: string | null = null
private currentGameName: string | null = null
private sessionStart: number | null = null
private store: StoreType
private onUpdate: (stats: TrackingStats) => void
private onGameStarted: (event: GameEvent) => void
private onGameStopped: (event: GameEvent) => void
constructor(
store: StoreType,
onUpdate: (stats: TrackingStats) => void,
onGameStarted?: (event: GameEvent) => void,
onGameStopped?: (event: GameEvent) => void
) {
this.store = store
this.onUpdate = onUpdate
this.onGameStarted = onGameStarted || (() => {})
this.onGameStopped = onGameStopped || (() => {})
}
start() {
const settings = this.store.get('settings')
const interval = settings.trackingInterval || 5000
this.intervalId = setInterval(() => this.tick(), interval)
console.log(`Process tracker started with ${interval}ms interval`)
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
// End current session if any
if (this.currentGame && this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
}
this.currentGame = null
this.sessionStart = null
console.log('Process tracker stopped')
}
private async tick() {
const foregroundProcess = await getForegroundWindow()
const trackedGames = getTrackedGames(this.store)
// Debug logging - ALWAYS log
console.log('[Tracker] Foreground:', foregroundProcess || 'NULL', '| Tracked:', trackedGames.length, 'games:', trackedGames.map(g => g.executableName).join(', ') || 'none')
// Find if foreground process matches any tracked game
let matchedGame: TrackedGame | null = null
if (foregroundProcess) {
const lowerForeground = foregroundProcess.toLowerCase().replace('.exe', '')
for (const game of trackedGames) {
const lowerExe = game.executableName.toLowerCase().replace('.exe', '')
// More flexible matching
const matches = lowerForeground === lowerExe ||
lowerForeground.includes(lowerExe) ||
lowerExe.includes(lowerForeground)
if (matches) {
console.log('[Tracker] MATCH:', foregroundProcess, '===', game.executableName)
matchedGame = game
break
}
}
}
// Handle game state changes
if (matchedGame && matchedGame.id !== this.currentGame) {
// New game started
if (this.currentGame && this.sessionStart && this.currentGameName) {
// End previous session
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event for previous game
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
this.currentGame = matchedGame.id
this.currentGameName = matchedGame.name
this.sessionStart = Date.now()
console.log(`Started tracking: ${matchedGame.name}`)
updateTrayMenu(null, true, matchedGame.name)
// Emit game started event
this.onGameStarted({
gameName: matchedGame.name,
gameId: matchedGame.id
})
} else if (!matchedGame && this.currentGame) {
// Game stopped
if (this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event
if (this.currentGameName) {
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
}
console.log(`Stopped tracking: ${this.currentGame}`)
this.currentGame = null
this.currentGameName = null
this.sessionStart = null
updateTrayMenu(null, false)
}
// Emit update
const stats = this.getStats()
this.onUpdate(stats)
}
private getStats(): TrackingStats {
const trackedGames = getTrackedGames(this.store)
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
// Add current session time if active
if (this.currentGame && this.sessionStart) {
const currentSessionTime = now - this.sessionStart
totalTimeToday += currentSessionTime
totalTimeWeek += currentSessionTime
totalTimeMonth += currentSessionTime
}
// This is a simplified version - full implementation would track sessions with timestamps
for (const game of trackedGames) {
totalTimeMonth += game.totalTime
// For simplicity, assume all recorded time is from this week/today
// A full implementation would store session timestamps
}
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: [],
currentGame: this.currentGameName,
currentSessionDuration: this.currentGame && this.sessionStart ? now - this.sessionStart : 0,
}
}
}

View File

@@ -0,0 +1,215 @@
import * as fs from 'fs'
import * as path from 'path'
import type { SteamGame } from '../../shared/types'
// Common Steam installation paths on Windows
const STEAM_PATHS = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
'D:\\Steam',
'D:\\SteamLibrary',
'E:\\Steam',
'E:\\SteamLibrary',
]
let cachedSteamPath: string | null = null
export function getSteamPath(): string | null {
if (cachedSteamPath) {
return cachedSteamPath
}
// Try common paths
for (const steamPath of STEAM_PATHS) {
if (fs.existsSync(path.join(steamPath, 'steam.exe')) ||
fs.existsSync(path.join(steamPath, 'steamapps'))) {
cachedSteamPath = steamPath
return steamPath
}
}
// Try to find via registry (would require node-winreg or similar)
// For now, just check common paths
return null
}
export async function getSteamGames(): Promise<SteamGame[]> {
const steamPath = getSteamPath()
if (!steamPath) {
console.log('Steam not found')
return []
}
const games: SteamGame[] = []
const libraryPaths = await getLibraryPaths(steamPath)
for (const libraryPath of libraryPaths) {
const steamAppsPath = path.join(libraryPath, 'steamapps')
if (!fs.existsSync(steamAppsPath)) continue
try {
const files = fs.readdirSync(steamAppsPath)
const manifests = files.filter((f) => f.startsWith('appmanifest_') && f.endsWith('.acf'))
for (const manifest of manifests) {
const game = await parseAppManifest(path.join(steamAppsPath, manifest), libraryPath)
if (game) {
games.push(game)
}
}
} catch (error) {
console.error(`Error reading steam apps from ${steamAppsPath}:`, error)
}
}
return games.sort((a, b) => a.name.localeCompare(b.name))
}
async function getLibraryPaths(steamPath: string): Promise<string[]> {
const paths: string[] = [steamPath]
const libraryFoldersPath = path.join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!fs.existsSync(libraryFoldersPath)) {
return paths
}
try {
const content = fs.readFileSync(libraryFoldersPath, 'utf8')
const libraryPaths = parseLibraryFolders(content)
paths.push(...libraryPaths.filter((p) => !paths.includes(p)))
} catch (error) {
console.error('Error reading library folders:', error)
}
return paths
}
function parseLibraryFolders(content: string): string[] {
const paths: string[] = []
// Simple VDF parser for library folders
// Format: "path" "C:\\SteamLibrary"
const pathRegex = /"path"\s+"([^"]+)"/g
let match
while ((match = pathRegex.exec(content)) !== null) {
const libPath = match[1].replace(/\\\\/g, '\\')
if (fs.existsSync(libPath)) {
paths.push(libPath)
}
}
return paths
}
async function parseAppManifest(manifestPath: string, libraryPath: string): Promise<SteamGame | null> {
try {
const content = fs.readFileSync(manifestPath, 'utf8')
const appIdMatch = content.match(/"appid"\s+"(\d+)"/)
const nameMatch = content.match(/"name"\s+"([^"]+)"/)
const installDirMatch = content.match(/"installdir"\s+"([^"]+)"/)
if (!appIdMatch || !nameMatch || !installDirMatch) {
return null
}
const appId = appIdMatch[1]
const name = nameMatch[1]
const installDir = installDirMatch[1]
// Filter out tools, servers, etc.
const skipTypes = ['Tool', 'Config', 'DLC', 'Music', 'Video']
const typeMatch = content.match(/"type"\s+"([^"]+)"/)
if (typeMatch && skipTypes.includes(typeMatch[1])) {
return null
}
const fullInstallPath = path.join(libraryPath, 'steamapps', 'common', installDir)
let executable: string | undefined
// Try to find main executable
if (fs.existsSync(fullInstallPath)) {
executable = findMainExecutable(fullInstallPath, name)
}
return {
appId,
name,
installDir: fullInstallPath,
executable,
iconPath: getGameIconPath(steamPath, appId),
}
} catch (error) {
console.error(`Error parsing manifest ${manifestPath}:`, error)
return null
}
}
function findMainExecutable(installPath: string, gameName: string): string | undefined {
try {
const files = fs.readdirSync(installPath)
const exeFiles = files.filter((f) => f.endsWith('.exe'))
if (exeFiles.length === 0) {
// Check subdirectories (one level deep)
for (const dir of files) {
const subPath = path.join(installPath, dir)
if (fs.statSync(subPath).isDirectory()) {
const subFiles = fs.readdirSync(subPath)
const subExe = subFiles.filter((f) => f.endsWith('.exe'))
exeFiles.push(...subExe.map((f) => path.join(dir, f)))
}
}
}
if (exeFiles.length === 0) return undefined
// Try to find exe that matches game name
const lowerName = gameName.toLowerCase().replace(/[^a-z0-9]/g, '')
for (const exe of exeFiles) {
const lowerExe = exe.toLowerCase().replace(/[^a-z0-9]/g, '')
if (lowerExe.includes(lowerName) || lowerName.includes(lowerExe.replace('.exe', ''))) {
return exe
}
}
// Filter out common non-game executables
const skipExes = [
'unins', 'setup', 'install', 'config', 'crash', 'report',
'launcher', 'updater', 'redistributable', 'vcredist', 'directx',
'dxsetup', 'ue4prereqsetup', 'dotnet',
]
const gameExes = exeFiles.filter((exe) => {
const lower = exe.toLowerCase()
return !skipExes.some((skip) => lower.includes(skip))
})
return gameExes[0] || exeFiles[0]
} catch {
return undefined
}
}
function getGameIconPath(steamPath: string | null, appId: string): string | undefined {
if (!steamPath) return undefined
// Steam stores icons in appcache/librarycache
const iconPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_icon.jpg`)
if (fs.existsSync(iconPath)) {
return iconPath
}
// Try header image
const headerPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_header.jpg`)
if (fs.existsSync(headerPath)) {
return headerPath
}
return undefined
}
// Re-export for use
const steamPath = getSteamPath()

View File

@@ -0,0 +1,155 @@
import type { TrackedGame, TrackingStats, GameSession } from '../../shared/types'
import type { StoreType, GameTrackingData } from '../storeTypes'
export type { GameTrackingData }
export function getTrackedGames(store: StoreType): TrackedGame[] {
const trackedGames = store.get('trackedGames') || {}
return Object.values(trackedGames)
}
export function addTrackedGame(
store: StoreType,
game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>
): TrackedGame {
const trackedGames = store.get('trackedGames') || {}
const newGame: TrackedGame = {
...game,
totalTime: 0,
lastPlayed: undefined,
}
trackedGames[game.id] = newGame
store.set('trackedGames', trackedGames)
// Initialize tracking data
const trackingData = store.get('trackingData') || {}
trackingData[game.id] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
store.set('trackingData', trackingData)
return newGame
}
export function removeTrackedGame(store: StoreType, gameId: string): void {
const trackedGames = store.get('trackedGames') || {}
delete trackedGames[gameId]
store.set('trackedGames', trackedGames)
const trackingData = store.get('trackingData') || {}
delete trackingData[gameId]
store.set('trackingData', trackingData)
}
export function updateGameTime(store: StoreType, gameId: string, duration: number): void {
// Update tracked games
const trackedGames = store.get('trackedGames') || {}
if (trackedGames[gameId]) {
trackedGames[gameId].totalTime += duration
trackedGames[gameId].lastPlayed = Date.now()
store.set('trackedGames', trackedGames)
}
// Update tracking data with session
const trackingData = store.get('trackingData') || {}
if (!trackingData[gameId]) {
trackingData[gameId] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
}
const now = Date.now()
trackingData[gameId].totalTime += duration
trackingData[gameId].lastPlayed = now
trackingData[gameId].sessions.push({
startTime: now - duration,
endTime: now,
duration,
})
// Keep only last 100 sessions to prevent data bloat
if (trackingData[gameId].sessions.length > 100) {
trackingData[gameId].sessions = trackingData[gameId].sessions.slice(-100)
}
store.set('trackingData', trackingData)
}
export function getTrackingStats(store: StoreType): TrackingStats {
const trackingData = store.get('trackingData') || {}
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
const recentSessions: GameSession[] = []
for (const [gameId, data] of Object.entries(trackingData)) {
for (const session of data.sessions) {
if (session.endTime >= monthStart) {
totalTimeMonth += session.duration
if (session.endTime >= weekStart) {
totalTimeWeek += session.duration
}
if (session.endTime >= todayStart) {
totalTimeToday += session.duration
}
}
}
// Get last session for each game
if (data.sessions.length > 0) {
const lastSession = data.sessions[data.sessions.length - 1]
const trackedGames = store.get('trackedGames') || {}
const game = trackedGames[gameId]
if (game && lastSession.endTime >= weekStart) {
recentSessions.push({
gameId,
gameName: game.name,
startTime: lastSession.startTime,
endTime: lastSession.endTime,
duration: lastSession.duration,
isActive: false,
})
}
}
}
// Sort by most recent
recentSessions.sort((a, b) => (b.endTime || 0) - (a.endTime || 0))
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: recentSessions.slice(0, 10),
}
}
export 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}с`
}
}

115
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Tray, Menu, nativeImage, BrowserWindow, app, NativeImage } from 'electron'
import * as path from 'path'
import type { StoreType } from './storeTypes'
let tray: Tray | null = null
export function setupTray(
mainWindow: BrowserWindow | null,
store: StoreType
) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// In dev: __dirname is dist/main/main/, in prod: same
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(__dirname, '../../../resources/icon.ico')
// Create tray icon
let trayIcon: NativeImage
try {
trayIcon = nativeImage.createFromPath(iconPath)
if (trayIcon.isEmpty()) {
trayIcon = nativeImage.createEmpty()
}
} catch {
trayIcon = nativeImage.createEmpty()
}
// Resize for tray (16x16 on Windows)
if (!trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 16, height: 16 })
}
tray = new Tray(trayIcon)
tray.setToolTip('Game Marathon Tracker')
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: 'Статус: Отслеживание',
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
// Double-click to show window
tray.on('double-click', () => {
mainWindow?.show()
mainWindow?.focus()
})
return tray
}
export function updateTrayMenu(
mainWindow: BrowserWindow | null,
isTracking: boolean,
currentGame?: string
) {
if (!tray) return
const statusLabel = isTracking
? `Отслеживание: ${currentGame || 'Активно'}`
: 'Отслеживание: Неактивно'
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: statusLabel,
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
}
export function destroyTray() {
if (tray) {
tray.destroy()
tray = null
}
}
export { tray }

187
desktop/src/main/updater.ts Normal file
View File

@@ -0,0 +1,187 @@
import { autoUpdater } from 'electron-updater'
import { BrowserWindow, ipcMain, app } from 'electron'
import * as path from 'path'
let splashWindow: BrowserWindow | null = null
export function createSplashWindow(): BrowserWindow {
splashWindow = new BrowserWindow({
width: 350,
height: 250,
frame: false,
transparent: false,
resizable: false,
center: true,
backgroundColor: '#0d0e14',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// In dev mode: __dirname is dist/main/main/, need to go up 3 levels to project root
splashWindow.loadFile(path.join(__dirname, '../../../src/renderer/splash.html'))
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
splashWindow.loadFile(path.join(__dirname, '../../renderer/splash.html'))
}
return splashWindow
}
export function closeSplashWindow() {
if (splashWindow) {
splashWindow.close()
splashWindow = null
}
}
function sendStatusToSplash(status: string) {
if (splashWindow) {
splashWindow.webContents.send('update-status', status)
}
}
function sendProgressToSplash(percent: number) {
if (splashWindow) {
splashWindow.webContents.send('update-progress', percent)
}
}
export function setupAutoUpdater(onComplete: () => void) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// In development, skip update check
if (isDev) {
console.log('[Updater] Skipping update check in development mode')
sendStatusToSplash('Режим разработки')
setTimeout(() => {
closeSplashWindow()
onComplete()
}, 1500)
return
}
// Configure auto-updater
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Check for updates
autoUpdater.on('checking-for-update', () => {
console.log('[Updater] Checking for updates...')
sendStatusToSplash('Проверка обновлений...')
})
autoUpdater.on('update-available', (info) => {
console.log('[Updater] Update available:', info.version)
sendStatusToSplash(`Найдено обновление v${info.version}`)
})
autoUpdater.on('update-not-available', () => {
console.log('[Updater] No updates available')
sendStatusToSplash('Актуальная версия')
setTimeout(() => {
closeSplashWindow()
onComplete()
}, 1000)
})
autoUpdater.on('download-progress', (progress) => {
const percent = Math.round(progress.percent)
console.log(`[Updater] Download progress: ${percent}%`)
sendStatusToSplash(`Загрузка обновления... ${percent}%`)
sendProgressToSplash(percent)
})
autoUpdater.on('update-downloaded', (info) => {
console.log('[Updater] Update downloaded:', info.version)
sendStatusToSplash('Установка обновления...')
// Install and restart
setTimeout(() => {
autoUpdater.quitAndInstall(false, true)
}, 1500)
})
autoUpdater.on('error', (error) => {
console.error('[Updater] Error:', error)
sendStatusToSplash('Ошибка проверки обновлений')
setTimeout(() => {
closeSplashWindow()
onComplete()
}, 2000)
})
// Start checking
autoUpdater.checkForUpdates().catch((error) => {
console.error('[Updater] Failed to check for updates:', error)
sendStatusToSplash('Не удалось проверить обновления')
setTimeout(() => {
closeSplashWindow()
onComplete()
}, 2000)
})
}
// Manual check for updates (from settings)
export function checkForUpdatesManual(): Promise<{ available: boolean; version?: string; error?: string }> {
return new Promise((resolve) => {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
resolve({ available: false, error: 'В режиме разработки обновления недоступны' })
return
}
const onUpdateAvailable = (info: { version: string }) => {
cleanup()
resolve({ available: true, version: info.version })
}
const onUpdateNotAvailable = () => {
cleanup()
resolve({ available: false })
}
const onError = (error: Error) => {
cleanup()
resolve({ available: false, error: error.message })
}
const cleanup = () => {
autoUpdater.off('update-available', onUpdateAvailable)
autoUpdater.off('update-not-available', onUpdateNotAvailable)
autoUpdater.off('error', onError)
}
autoUpdater.on('update-available', onUpdateAvailable)
autoUpdater.on('update-not-available', onUpdateNotAvailable)
autoUpdater.on('error', onError)
autoUpdater.checkForUpdates().catch((error) => {
cleanup()
resolve({ available: false, error: error.message })
})
})
}
// Setup IPC handlers for updates
export function setupUpdateIpcHandlers() {
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
ipcMain.handle('check-for-updates', async () => {
return await checkForUpdatesManual()
})
ipcMain.handle('download-update', () => {
autoUpdater.downloadUpdate()
})
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true)
})
}

View File

@@ -0,0 +1,96 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '../shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
const electronAPI = {
// Settings
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('get-settings'),
saveSettings: (settings: Partial<AppSettings>): Promise<void> =>
ipcRenderer.invoke('save-settings', settings),
// Auth (local storage)
getToken: (): Promise<string | null> => ipcRenderer.invoke('get-token'),
saveToken: (token: string): Promise<void> => ipcRenderer.invoke('save-token', token),
clearToken: (): Promise<void> => ipcRenderer.invoke('clear-token'),
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-login', login, password),
api2faVerify: (sessionId: number, code: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-2fa-verify', sessionId, code),
apiGetMe: (): Promise<ApiResult<User>> =>
ipcRenderer.invoke('api-get-me'),
apiRequest: <T>(method: string, endpoint: string, data?: unknown): Promise<ApiResult<T>> =>
ipcRenderer.invoke('api-request', method, endpoint, data),
// Process tracking
getRunningProcesses: (): Promise<TrackedProcess[]> =>
ipcRenderer.invoke('get-running-processes'),
getForegroundWindow: (): Promise<string | null> =>
ipcRenderer.invoke('get-foreground-window'),
getTrackingStats: (): Promise<TrackingStats> =>
ipcRenderer.invoke('get-tracking-stats'),
// Steam
getSteamGames: (): Promise<SteamGame[]> => ipcRenderer.invoke('get-steam-games'),
getSteamPath: (): Promise<string | null> => ipcRenderer.invoke('get-steam-path'),
// Tracked games
getTrackedGames: (): Promise<TrackedGame[]> => ipcRenderer.invoke('get-tracked-games'),
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>): Promise<TrackedGame> =>
ipcRenderer.invoke('add-tracked-game', game),
removeTrackedGame: (gameId: string): Promise<void> =>
ipcRenderer.invoke('remove-tracked-game', gameId),
// Window controls
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
quitApp: (): void => ipcRenderer.send('quit-app'),
// Monitoring control
startMonitoring: (): Promise<boolean> => ipcRenderer.invoke('start-monitoring'),
stopMonitoring: (): Promise<boolean> => ipcRenderer.invoke('stop-monitoring'),
getMonitoringStatus: (): Promise<boolean> => ipcRenderer.invoke('get-monitoring-status'),
// Updates
getAppVersion: (): Promise<string> => ipcRenderer.invoke('get-app-version'),
checkForUpdates: (): Promise<{ available: boolean; version?: string; error?: string }> =>
ipcRenderer.invoke('check-for-updates'),
installUpdate: (): Promise<void> => ipcRenderer.invoke('install-update'),
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, stats: TrackingStats) => callback(stats)
ipcRenderer.on('tracking-update', subscription)
return () => ipcRenderer.removeListener('tracking-update', subscription)
},
onGameStarted: (callback: (gameName: string, gameId: string) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, gameId: string) => callback(gameName, gameId)
ipcRenderer.on('game-started', subscription)
return () => ipcRenderer.removeListener('game-started', subscription)
},
onGameStopped: (callback: (gameName: string, duration: number) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, duration: number) =>
callback(gameName, duration)
ipcRenderer.on('game-stopped', subscription)
return () => ipcRenderer.removeListener('game-stopped', subscription)
},
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
// Type declaration for renderer process
declare global {
interface Window {
electronAPI: typeof electronAPI
}
}

View File

@@ -0,0 +1,65 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './store/auth'
import { Layout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
import { SettingsPage } from './pages/SettingsPage'
import { GamesPage } from './pages/GamesPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
export default function App() {
const { syncUser } = useAuthStore()
useEffect(() => {
syncUser()
}, [syncUser])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/games"
element={
<ProtectedRoute>
<GamesPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,70 @@
import { ReactNode } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Gamepad2, Settings, LayoutDashboard, X, Minus } from 'lucide-react'
import { clsx } from 'clsx'
interface LayoutProps {
children: ReactNode
}
export function Layout({ children }: LayoutProps) {
const location = useLocation()
const navItems = [
{ path: '/', icon: LayoutDashboard, label: 'Главная' },
{ path: '/games', icon: Gamepad2, label: 'Игры' },
{ path: '/settings', icon: Settings, label: 'Настройки' },
]
return (
<div className="h-screen bg-dark-900 flex flex-col overflow-hidden">
{/* 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>
{/* Main content */}
<div className="flex-1 overflow-auto p-4">{children}</div>
{/* Bottom navigation */}
<nav className="bg-dark-800 border-t border-dark-700 px-2 py-2">
<div className="flex justify-around">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
clsx(
'flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all',
isActive
? 'text-neon-500 bg-neon-500/10'
: 'text-gray-400 hover:text-gray-300 hover:bg-dark-700'
)
}
>
<item.icon className="w-5 h-5" />
<span className="text-xs">{item.label}</span>
</NavLink>
))}
</div>
</nav>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
}
return (
<div
className={clsx(
'rounded-xl p-4',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
icon?: React.ReactNode
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, icon, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{icon}
</div>
)}
<input
ref={ref}
className={clsx(
'w-full bg-dark-800 border border-dark-600 rounded-lg px-4 py-2.5',
'text-white placeholder-gray-500',
'transition-all duration-200',
'focus:border-neon-500/50 focus:ring-2 focus:ring-neon-500/10',
'disabled:opacity-50 disabled:cursor-not-allowed',
icon && 'pl-10',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/10',
className
)}
{...props}
/>
</div>
{error && (
<p className="mt-1.5 text-sm text-red-400">{error}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,118 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
},
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost' ? colors.glow : undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'

View File

@@ -0,0 +1,134 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #14161e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #252732;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #2e313d;
}
/* Glass effect */
.glass {
background: rgba(20, 22, 30, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.1);
}
.glass-dark {
background: rgba(13, 14, 20, 0.9);
backdrop-filter: blur(16px);
border: 1px solid rgba(34, 211, 238, 0.08);
}
.glass-neon {
background: rgba(20, 22, 30, 0.85);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.2);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.08);
}
/* Neon glow effect */
.neon-glow {
box-shadow: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
}
.neon-glow-purple {
box-shadow: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
}
.neon-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
/* Gradient border */
.gradient-border {
position: relative;
background: #14161e;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -1px;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg, #22d3ee, #8b5cf6, #f472b6);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.5;
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.3);
box-shadow: 0 4px 20px rgba(34, 211, 238, 0.1);
}
/* Title bar drag region */
.titlebar {
-webkit-app-region: drag;
}
.titlebar button {
-webkit-app-region: no-drag;
}
/* Live indicator */
.live-indicator {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: pulse-live 2s infinite;
}
@keyframes pulse-live {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* Input focus styles */
input:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.5);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.1);
}
/* Selection */
::selection {
background: rgba(34, 211, 238, 0.3);
}
/* Transition utilities */
.transition-all-300 {
transition: all 0.3s ease;
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* https://*; img-src 'self' data: https:">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<title>Game Marathon Tracker</title>
</head>
<body class="bg-dark-900 text-white antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

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>
)
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Marathon Tracker</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0e14;
color: white;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
-webkit-app-region: drag;
}
.logo-img {
width: 120px;
height: 120px;
margin-bottom: 20px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.5)); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.7)); }
}
.status {
font-size: 13px;
color: #9ca3af;
text-align: center;
min-height: 20px;
margin-top: 10px;
}
.progress-container {
width: 200px;
height: 4px;
background: rgba(34, 211, 238, 0.1);
border-radius: 2px;
margin-top: 15px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #22d3ee, #8b5cf6);
border-radius: 2px;
width: 0%;
transition: width 0.3s ease;
}
.version {
position: absolute;
bottom: 15px;
font-size: 11px;
color: #6b7280;
}
</style>
</head>
<body>
<img src="logo.jpg" alt="Logo" class="logo-img">
<div class="status" id="status">Проверка обновлений...</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="version" id="version"></div>
<script>
const { ipcRenderer } = require('electron');
// Get current version
ipcRenderer.invoke('get-app-version').then(version => {
document.getElementById('version').textContent = `v${version}`;
});
// Listen for status updates
ipcRenderer.on('update-status', (event, status) => {
document.getElementById('status').textContent = status;
});
// Listen for download progress
ipcRenderer.on('update-progress', (event, percent) => {
const container = document.getElementById('progressContainer');
const bar = document.getElementById('progressBar');
container.style.display = 'block';
bar.style.width = `${percent}%`;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
import { create } from 'zustand'
import type { User } from '@shared/types'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
// 2FA state
requires2fa: boolean
twoFactorSessionId: number | null
login: (login: string, password: string) => Promise<boolean>
verify2fa: (code: string) => Promise<boolean>
logout: () => Promise<void>
syncUser: () => Promise<void>
clearError: () => void
reset2fa: () => void
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
error: null,
requires2fa: false,
twoFactorSessionId: null,
login: async (login: string, password: string) => {
set({ isLoading: true, error: null, requires2fa: false, twoFactorSessionId: null })
const result = await window.electronAPI.apiLogin(login, password)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Ошибка авторизации',
})
return false
}
const response = result.data!
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
requires2fa: true,
twoFactorSessionId: response.two_factor_session_id,
})
return false // Not fully logged in yet
}
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
return true
}
set({ isLoading: false })
return false
},
verify2fa: async (code: string) => {
const sessionId = get().twoFactorSessionId
if (!sessionId) {
set({ error: 'Нет активной сессии 2FA' })
return false
}
set({ isLoading: true, error: null })
const result = await window.electronAPI.api2faVerify(sessionId, code)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Неверный код',
})
return false
}
const response = result.data!
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
requires2fa: false,
twoFactorSessionId: null,
})
return true
}
set({ isLoading: false })
return false
},
reset2fa: () => set({ requires2fa: false, twoFactorSessionId: null, error: null }),
logout: async () => {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
syncUser: async () => {
set({ isLoading: true })
const token = await window.electronAPI.getToken()
if (!token) {
set({ isLoading: false, isAuthenticated: false })
return
}
const result = await window.electronAPI.apiGetMe()
if (!result.success) {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
return
}
set({
user: result.data!,
token,
isAuthenticated: true,
isLoading: false,
})
},
clearError: () => set({ error: null }),
}))

View File

@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { MarathonResponse, AssignmentResponse } from '@shared/types'
interface MarathonState {
marathons: MarathonResponse[]
selectedMarathonId: number | null
currentAssignment: AssignmentResponse | null
isLoading: boolean
error: string | null
loadMarathons: () => Promise<void>
selectMarathon: (marathonId: number) => Promise<void>
loadCurrentAssignment: () => Promise<void>
syncTime: (minutes: number) => Promise<void>
reset: () => void
}
export const useMarathonStore = create<MarathonState>()(
persist(
(set, get) => ({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null,
loadMarathons: async () => {
set({ isLoading: true, error: null })
const result = await window.electronAPI.apiRequest<MarathonResponse[]>('GET', '/marathons')
if (!result.success) {
set({ isLoading: false, error: result.error || 'Failed to load marathons' })
return
}
const marathons = result.data || []
const activeMarathons = marathons.filter(m => m.status === 'active')
set({ marathons: activeMarathons, isLoading: false })
// If we have a selected marathon, verify it's still valid
const { selectedMarathonId } = get()
if (selectedMarathonId) {
const stillExists = activeMarathons.some(m => m.id === selectedMarathonId)
if (!stillExists && activeMarathons.length > 0) {
// Select first available marathon
await get().selectMarathon(activeMarathons[0].id)
} else if (stillExists) {
// Reload assignment for current selection
await get().loadCurrentAssignment()
}
} else if (activeMarathons.length > 0) {
// No selection, select first marathon
await get().selectMarathon(activeMarathons[0].id)
}
},
selectMarathon: async (marathonId: number) => {
set({ selectedMarathonId: marathonId, currentAssignment: null })
await get().loadCurrentAssignment()
},
loadCurrentAssignment: async () => {
const { selectedMarathonId } = get()
if (!selectedMarathonId) {
set({ currentAssignment: null })
return
}
const result = await window.electronAPI.apiRequest<AssignmentResponse | null>(
'GET',
`/marathons/${selectedMarathonId}/current-assignment`
)
if (result.success) {
set({ currentAssignment: result.data ?? null })
} else {
// User might not be participant of this marathon
set({ currentAssignment: null, error: result.error })
}
},
syncTime: async (minutes: number) => {
const { currentAssignment } = get()
if (!currentAssignment || currentAssignment.status !== 'active') {
return
}
const result = await window.electronAPI.apiRequest(
'PATCH',
`/assignments/${currentAssignment.id}/track-time`,
{ minutes }
)
if (result.success) {
// Update local assignment with new tracked time
set({
currentAssignment: {
...currentAssignment,
tracked_time_minutes: minutes
}
})
}
},
reset: () => {
set({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null
})
}
}),
{
name: 'marathon-storage',
partialize: (state) => ({ selectedMarathonId: state.selectedMarathonId })
}
)
)

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand'
import type { TrackedGame, TrackingStats, SteamGame } from '@shared/types'
interface TrackingState {
trackedGames: TrackedGame[]
steamGames: SteamGame[]
stats: TrackingStats | null
currentGame: string | null
isLoading: boolean
loadTrackedGames: () => Promise<void>
loadSteamGames: () => Promise<void>
addGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<void>
removeGame: (gameId: string) => Promise<void>
updateStats: (stats: TrackingStats) => void
setCurrentGame: (gameName: string | null) => void
}
export const useTrackingStore = create<TrackingState>((set) => ({
trackedGames: [],
steamGames: [],
stats: null,
currentGame: null,
isLoading: false,
loadTrackedGames: async () => {
set({ isLoading: true })
try {
const games = await window.electronAPI.getTrackedGames()
set({ trackedGames: games, isLoading: false })
} catch (error) {
console.error('Failed to load tracked games:', error)
set({ isLoading: false })
}
},
loadSteamGames: async () => {
try {
const games = await window.electronAPI.getSteamGames()
set({ steamGames: games })
} catch (error) {
console.error('Failed to load Steam games:', error)
}
},
addGame: async (game) => {
try {
const newGame = await window.electronAPI.addTrackedGame(game)
set((state) => ({
trackedGames: [...state.trackedGames, newGame],
}))
} catch (error) {
console.error('Failed to add game:', error)
throw error
}
},
removeGame: async (gameId) => {
try {
await window.electronAPI.removeTrackedGame(gameId)
set((state) => ({
trackedGames: state.trackedGames.filter((g) => g.id !== gameId),
}))
} catch (error) {
console.error('Failed to remove game:', error)
throw error
}
},
updateStats: (stats) => {
if (stats.currentGame) {
console.log('[Tracking] Current game:', stats.currentGame, 'Session:', Math.floor((stats.currentSessionDuration || 0) / 1000), 's')
}
set({
stats,
currentGame: stats.currentGame || null
})
},
setCurrentGame: (gameName) => set({ currentGame: gameName }),
}))

54
desktop/src/renderer/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
/// <reference types="vite/client" />
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '@shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
declare global {
interface Window {
electronAPI: {
// Settings
getSettings: () => Promise<AppSettings>
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
// Auth (local storage)
getToken: () => Promise<string | null>
saveToken: (token: string) => Promise<void>
clearToken: () => Promise<void>
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string) => Promise<ApiResult<LoginResponse>>
api2faVerify: (sessionId: number, code: string) => Promise<ApiResult<LoginResponse>>
apiGetMe: () => Promise<ApiResult<User>>
apiRequest: <T>(method: string, endpoint: string, data?: unknown) => Promise<ApiResult<T>>
// Process tracking
getRunningProcesses: () => Promise<TrackedProcess[]>
getForegroundWindow: () => Promise<string | null>
getTrackingStats: () => Promise<TrackingStats>
// Steam
getSteamGames: () => Promise<SteamGame[]>
getSteamPath: () => Promise<string | null>
// Tracked games
getTrackedGames: () => Promise<TrackedGame[]>
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<TrackedGame>
removeTrackedGame: (gameId: string) => Promise<void>
// Window controls
minimizeToTray: () => void
quitApp: () => void
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => () => void
onGameStarted: (callback: (gameName: string) => void) => () => void
onGameStopped: (callback: (gameName: string, duration: number) => void) => () => void
}
}
}

226
desktop/src/shared/types.ts Normal file
View File

@@ -0,0 +1,226 @@
// Shared types between main and renderer processes
export interface User {
id: number
login: string
nickname: string
avatar_url?: string
role: 'USER' | 'ADMIN'
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
export interface LoginResponse {
access_token?: string
token_type?: string
user?: User
requires_2fa?: boolean
two_factor_session_id?: number
}
// API Response types
export interface MarathonResponse {
id: number
title: string
description?: string
status: 'preparing' | 'active' | 'finished'
start_date?: string
end_date?: string
cover_url?: string
is_public: boolean
participation?: {
is_organizer: boolean
points: number
completed_count: number
dropped_count: number
}
}
export interface GameShort {
id: number
title: string
cover_url?: string
download_url?: string
game_type?: 'challenges' | 'playthrough'
}
export interface ChallengeResponse {
id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time?: number
proof_type: string
proof_hint?: string
game: GameShort
is_generated: boolean
}
export interface PlaythroughInfo {
description?: string
points: number
proof_type: string
proof_hint?: string
}
export interface AssignmentResponse {
id: number
challenge?: ChallengeResponse
game: GameShort
is_playthrough: boolean
playthrough_info?: PlaythroughInfo
status: 'active' | 'completed' | 'dropped' | 'returned'
proof_url?: string
proof_comment?: string
points_earned: number
tracked_time_minutes: number
started_at: string
completed_at?: string
can_drop: boolean
drop_penalty: number
event_type?: string
}
export interface Marathon {
id: number
title: string
description?: string
status: 'lobby' | 'active' | 'finished'
start_date: string
end_date?: string
cover_url?: string
}
export interface Game {
id: number
name: string
genre?: string
cover_url?: string
marathon_id: number
}
export interface Challenge {
id: number
game_id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time: number
proof_type: string
proof_hint?: string
}
export interface Assignment {
id: number
participant_id: number
game_id: number
challenge_id?: number
game: Game
challenge?: Challenge
status: 'active' | 'completed' | 'dropped'
started_at: string
completed_at?: string
time_spent_minutes?: number
}
export interface CurrentChallenge {
marathon: Marathon
assignment?: Assignment
}
// Process tracking types
export interface TrackedProcess {
id: string
name: string
displayName: string
executablePath?: string
windowTitle?: string
isGame: boolean
steamAppId?: string
}
export interface GameSession {
gameId: string
gameName: string
startTime: number
endTime?: number
duration: number
isActive: boolean
}
export interface TrackingStats {
totalTimeToday: number
totalTimeWeek: number
totalTimeMonth: number
sessions: GameSession[]
currentGame?: string | null
currentSessionDuration?: number
}
export interface SteamGame {
appId: string
name: string
installDir: string
executable?: string
iconPath?: string
}
export interface TrackedGame {
id: string
name: string
executableName: string
executablePath?: string
steamAppId?: string
iconPath?: string
totalTime: number
lastPlayed?: number
}
export interface AppSettings {
autoLaunch: boolean
minimizeToTray: boolean
trackingInterval: number
apiUrl: string
theme: 'dark'
}
// IPC Channel types
export interface IpcChannels {
// Settings
'get-settings': () => AppSettings
'save-settings': (settings: Partial<AppSettings>) => void
// Auth
'get-token': () => string | null
'save-token': (token: string) => void
'clear-token': () => void
// Process tracking
'get-running-processes': () => TrackedProcess[]
'get-foreground-window': () => string | null
'start-tracking': (processName: string) => void
'stop-tracking': () => void
'get-tracking-stats': () => TrackingStats
// Steam
'get-steam-games': () => SteamGame[]
'get-steam-path': () => string | null
// Tracked games
'get-tracked-games': () => TrackedGame[]
'add-tracked-game': (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => TrackedGame
'remove-tracked-game': (gameId: string) => void
'update-game-time': (gameId: string, time: number) => void
// Window
'minimize-to-tray': () => void
'quit-app': () => void
}

147
desktop/tailwind.config.js Normal file
View File

@@ -0,0 +1,147 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/renderer/index.html",
"./src/renderer/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
dark: {
950: '#08090d',
900: '#0d0e14',
800: '#14161e',
700: '#1c1e28',
600: '#252732',
500: '#2e313d',
},
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out forwards',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2)'
},
'50%': {
boxShadow: '0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3)'
},
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
'shimmer': {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'slide-in-up': {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'bounce-in': {
'0%': { opacity: '0', transform: 'scale(0.3)' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-neon': {
'0%, 100%': {
textShadow: '0 0 6px rgba(34, 211, 238, 0.5), 0 0 12px rgba(34, 211, 238, 0.25)'
},
'50%': {
textShadow: '0 0 10px rgba(34, 211, 238, 0.6), 0 0 18px rgba(34, 211, 238, 0.35)'
},
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'neon-glow': 'linear-gradient(90deg, #22d3ee, #8b5cf6, #22d3ee)',
'cyber-grid': `
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2)',
'neon-lg': '0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3)',
'neon-purple': '0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2)',
'neon-pink': '0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2)',
'inner-glow': 'inset 0 0 20px rgba(34, 211, 238, 0.06)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
},
},
plugins: [],
}

26
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src/renderer/**/*", "src/shared/**/*"],
"references": [{ "path": "./tsconfig.main.json" }]
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "dist/main",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src/main/**/*", "src/shared/**/*", "src/preload/**/*"]
}

22
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer'),
'@shared': path.resolve(__dirname, 'src/shared'),
},
},
server: {
port: 5173,
},
})

View File

@@ -1418,9 +1418,28 @@ export function PlayPage() {
</div> </div>
<div className="flex items-center gap-3 mb-6 flex-wrap"> <div className="flex items-center gap-3 mb-6 flex-wrap">
{/* Points - calculated from tracked time if available */}
{currentAssignment.tracked_time_minutes !== undefined && currentAssignment.tracked_time_minutes > 0 ? (
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
~{Math.floor(currentAssignment.tracked_time_minutes / 60 * 30)} очков
</span>
) : (
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.playthrough_info?.points} очков +{currentAssignment.playthrough_info?.points} очков
</span> </span>
)}
{/* Time tracker indicator */}
{currentAssignment.tracked_time_minutes !== undefined && currentAssignment.tracked_time_minutes > 0 ? (
<span className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{Math.floor(currentAssignment.tracked_time_minutes / 60)}ч {currentAssignment.tracked_time_minutes % 60}м
</span>
) : (
<span className="px-3 py-1.5 bg-gray-500/20 text-gray-400 rounded-lg text-sm font-medium border border-gray-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
Установите трекер
</span>
)}
</div> </div>
{currentAssignment.playthrough_info?.proof_hint && ( {currentAssignment.playthrough_info?.proof_hint && (

View File

@@ -270,6 +270,7 @@ export interface Assignment {
proof_comment: string | null proof_comment: string | null
points_earned: number points_earned: number
streak_at_completion: number | null streak_at_completion: number | null
tracked_time_minutes?: number // Time tracked by desktop app (for playthroughs)
started_at: string started_at: string
completed_at: string | null completed_at: string | null
drop_penalty: number drop_penalty: number