Compare commits
2 Commits
3256c40841
...
2b6f2888ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b6f2888ee | |||
| b6eecc4483 |
30
backend/alembic/versions/029_add_tracked_time.py
Normal file
30
backend/alembic/versions/029_add_tracked_time.py
Normal 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')
|
||||||
@@ -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,7 +590,14 @@ 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
|
||||||
base_playthrough_points = game.playthrough_points
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
|
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
|
||||||
base_bonus_points = sum(
|
base_bonus_points = sum(
|
||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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=["*"],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
28
desktop/.gitignore
vendored
Normal 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
6893
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
desktop/package.json
Normal file
80
desktop/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
2
desktop/resources/.gitkeep
Normal file
2
desktop/resources/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Resources placeholder
|
||||||
|
# Add icon.ico and tray-icon.png here
|
||||||
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
desktop/resources/logo.jpg
Normal file
BIN
desktop/resources/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
145
desktop/src/main/apiClient.ts
Normal file
145
desktop/src/main/apiClient.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
desktop/src/main/autolaunch.ts
Normal file
42
desktop/src/main/autolaunch.ts
Normal 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
181
desktop/src/main/index.ts
Normal 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
174
desktop/src/main/ipc.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
28
desktop/src/main/storeTypes.ts
Normal file
28
desktop/src/main/storeTypes.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
284
desktop/src/main/tracking/processTracker.ts
Normal file
284
desktop/src/main/tracking/processTracker.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
desktop/src/main/tracking/steamIntegration.ts
Normal file
215
desktop/src/main/tracking/steamIntegration.ts
Normal 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()
|
||||||
155
desktop/src/main/tracking/timeStorage.ts
Normal file
155
desktop/src/main/tracking/timeStorage.ts
Normal 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
115
desktop/src/main/tray.ts
Normal 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
187
desktop/src/main/updater.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
96
desktop/src/preload/index.ts
Normal file
96
desktop/src/preload/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
65
desktop/src/renderer/App.tsx
Normal file
65
desktop/src/renderer/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
desktop/src/renderer/components/Layout.tsx
Normal file
70
desktop/src/renderer/components/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal file
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
desktop/src/renderer/components/ui/Input.tsx
Normal file
48
desktop/src/renderer/components/ui/Input.tsx
Normal 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'
|
||||||
118
desktop/src/renderer/components/ui/NeonButton.tsx
Normal file
118
desktop/src/renderer/components/ui/NeonButton.tsx
Normal 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'
|
||||||
134
desktop/src/renderer/index.css
Normal file
134
desktop/src/renderer/index.css
Normal 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;
|
||||||
|
}
|
||||||
16
desktop/src/renderer/index.html
Normal file
16
desktop/src/renderer/index.html
Normal 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>
|
||||||
BIN
desktop/src/renderer/logo.jpg
Normal file
BIN
desktop/src/renderer/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
13
desktop/src/renderer/main.tsx
Normal file
13
desktop/src/renderer/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
481
desktop/src/renderer/pages/DashboardPage.tsx
Normal file
481
desktop/src/renderer/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Clock, Gamepad2, Plus, Trophy, Target, Loader2, ChevronDown, Timer, Play, Square } from 'lucide-react'
|
||||||
|
import { useTrackingStore } from '../store/tracking'
|
||||||
|
import { useAuthStore } from '../store/auth'
|
||||||
|
import { useMarathonStore } from '../store/marathon'
|
||||||
|
import { GlassCard } from '../components/ui/GlassCard'
|
||||||
|
import { NeonButton } from '../components/ui/NeonButton'
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
const remainingMinutes = minutes % 60
|
||||||
|
return `${hours}ч ${remainingMinutes}м`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}м`
|
||||||
|
} else {
|
||||||
|
return `${seconds}с`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}ч ${mins}м`
|
||||||
|
}
|
||||||
|
return `${mins}м`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDifficultyColor(difficulty: string): string {
|
||||||
|
switch (difficulty) {
|
||||||
|
case 'easy': return 'text-green-400'
|
||||||
|
case 'medium': return 'text-yellow-400'
|
||||||
|
case 'hard': return 'text-red-400'
|
||||||
|
default: return 'text-gray-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDifficultyLabel(difficulty: string): string {
|
||||||
|
switch (difficulty) {
|
||||||
|
case 'easy': return 'Легкий'
|
||||||
|
case 'medium': return 'Средний'
|
||||||
|
case 'hard': return 'Сложный'
|
||||||
|
default: return difficulty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const { trackedGames, stats, currentGame, loadTrackedGames, updateStats } = useTrackingStore()
|
||||||
|
const {
|
||||||
|
marathons,
|
||||||
|
selectedMarathonId,
|
||||||
|
currentAssignment,
|
||||||
|
isLoading,
|
||||||
|
loadMarathons,
|
||||||
|
selectMarathon,
|
||||||
|
syncTime
|
||||||
|
} = useMarathonStore()
|
||||||
|
|
||||||
|
// Monitoring state
|
||||||
|
const [isMonitoring, setIsMonitoring] = useState(false)
|
||||||
|
const [localSessionSeconds, setLocalSessionSeconds] = useState(0)
|
||||||
|
|
||||||
|
// Refs for time tracking sync
|
||||||
|
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const lastSyncedMinutesRef = useRef<number>(0)
|
||||||
|
const sessionStartRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// Check if we should track time: any tracked game is running + active assignment exists
|
||||||
|
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
|
||||||
|
|
||||||
|
// Sync time to server
|
||||||
|
const doSyncTime = useCallback(async () => {
|
||||||
|
if (!currentAssignment || !isTrackingAssignment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total minutes: previous tracked + current session
|
||||||
|
const sessionDuration = sessionStartRef.current
|
||||||
|
? Math.floor((Date.now() - sessionStartRef.current) / 60000)
|
||||||
|
: 0
|
||||||
|
const totalMinutes = currentAssignment.tracked_time_minutes + sessionDuration
|
||||||
|
|
||||||
|
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
|
||||||
|
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${currentAssignment.id}`)
|
||||||
|
await syncTime(totalMinutes)
|
||||||
|
lastSyncedMinutesRef.current = totalMinutes
|
||||||
|
}
|
||||||
|
}, [currentAssignment, isTrackingAssignment, syncTime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTrackedGames()
|
||||||
|
loadMarathons()
|
||||||
|
|
||||||
|
// Load monitoring status
|
||||||
|
window.electronAPI.getMonitoringStatus().then(setIsMonitoring)
|
||||||
|
|
||||||
|
// Subscribe to tracking updates
|
||||||
|
const unsubscribe = window.electronAPI.onTrackingUpdate((newStats) => {
|
||||||
|
updateStats(newStats)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to game started event
|
||||||
|
const unsubGameStarted = window.electronAPI.onGameStarted((gameName, _gameId) => {
|
||||||
|
console.log(`[Game] Started: ${gameName}`)
|
||||||
|
sessionStartRef.current = Date.now()
|
||||||
|
setLocalSessionSeconds(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to game stopped event
|
||||||
|
const unsubGameStopped = window.electronAPI.onGameStopped((gameName, _duration) => {
|
||||||
|
console.log(`[Game] Stopped: ${gameName}`)
|
||||||
|
sessionStartRef.current = null
|
||||||
|
setLocalSessionSeconds(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get initial stats
|
||||||
|
window.electronAPI.getTrackingStats().then(updateStats)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
unsubGameStarted()
|
||||||
|
unsubGameStopped()
|
||||||
|
}
|
||||||
|
}, [loadTrackedGames, loadMarathons, updateStats])
|
||||||
|
|
||||||
|
// Setup sync interval and local timer when tracking
|
||||||
|
useEffect(() => {
|
||||||
|
let localTimerInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
if (isTrackingAssignment) {
|
||||||
|
// Start session if not already started
|
||||||
|
if (!sessionStartRef.current) {
|
||||||
|
sessionStartRef.current = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync immediately when game starts
|
||||||
|
doSyncTime()
|
||||||
|
|
||||||
|
// Setup periodic sync every 60 seconds
|
||||||
|
syncIntervalRef.current = setInterval(() => {
|
||||||
|
doSyncTime()
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
// Update local timer every second for UI
|
||||||
|
localTimerInterval = setInterval(() => {
|
||||||
|
if (sessionStartRef.current) {
|
||||||
|
setLocalSessionSeconds(Math.floor((Date.now() - sessionStartRef.current) / 1000))
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Do final sync when game stops
|
||||||
|
if (syncIntervalRef.current) {
|
||||||
|
doSyncTime()
|
||||||
|
clearInterval(syncIntervalRef.current)
|
||||||
|
syncIntervalRef.current = null
|
||||||
|
sessionStartRef.current = null
|
||||||
|
}
|
||||||
|
setLocalSessionSeconds(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (syncIntervalRef.current) {
|
||||||
|
clearInterval(syncIntervalRef.current)
|
||||||
|
syncIntervalRef.current = null
|
||||||
|
}
|
||||||
|
if (localTimerInterval) {
|
||||||
|
clearInterval(localTimerInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isTrackingAssignment, doSyncTime])
|
||||||
|
|
||||||
|
// Toggle monitoring
|
||||||
|
const toggleMonitoring = async () => {
|
||||||
|
if (isMonitoring) {
|
||||||
|
await window.electronAPI.stopMonitoring()
|
||||||
|
setIsMonitoring(false)
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.startMonitoring()
|
||||||
|
setIsMonitoring(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayTime = stats?.totalTimeToday || 0
|
||||||
|
const weekTime = stats?.totalTimeWeek || 0
|
||||||
|
|
||||||
|
const selectedMarathon = marathons.find(m => m.id === selectedMarathonId)
|
||||||
|
|
||||||
|
const renderCurrentChallenge = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marathons.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Нет активных марафонов. Присоединитесь к марафону на сайте.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentAssignment) {
|
||||||
|
return (
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Нет активного задания. Крутите колесо на сайте!
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = currentAssignment
|
||||||
|
|
||||||
|
// Playthrough assignment
|
||||||
|
if (assignment.is_playthrough && assignment.playthrough_info) {
|
||||||
|
// Use localSessionSeconds for live display (updates every second)
|
||||||
|
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
|
||||||
|
const totalSeconds = (assignment.tracked_time_minutes * 60) + sessionSeconds
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||||
|
const trackedHours = totalMinutes / 60
|
||||||
|
const estimatedPoints = Math.floor(trackedHours * 30)
|
||||||
|
|
||||||
|
// Format with seconds when actively tracking
|
||||||
|
const formatLiveTime = () => {
|
||||||
|
if (isTrackingAssignment && sessionSeconds > 0) {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const mins = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const secs = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}ч ${mins}м ${secs}с`
|
||||||
|
}
|
||||||
|
return `${mins}м ${secs}с`
|
||||||
|
}
|
||||||
|
return formatMinutes(totalMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-medium text-white">
|
||||||
|
Прохождение: {assignment.game.title}
|
||||||
|
</h3>
|
||||||
|
{isTrackingAssignment && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs text-green-400">
|
||||||
|
<div className="live-indicator" />
|
||||||
|
Идёт запись
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{assignment.playthrough_info.description && (
|
||||||
|
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
|
||||||
|
{assignment.playthrough_info.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs flex-wrap">
|
||||||
|
{totalSeconds > 0 || isTrackingAssignment ? (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1 text-neon-400">
|
||||||
|
<Timer className="w-3 h-3" />
|
||||||
|
{formatLiveTime()}
|
||||||
|
</span>
|
||||||
|
<span className="text-neon-400 font-medium">
|
||||||
|
~{estimatedPoints} очков
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Базово: {assignment.playthrough_info.points} очков
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge assignment
|
||||||
|
if (assignment.challenge) {
|
||||||
|
const challenge = assignment.challenge
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-white mb-1">{challenge.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">{challenge.game.title}</p>
|
||||||
|
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
|
||||||
|
{challenge.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className={getDifficultyColor(challenge.difficulty)}>
|
||||||
|
[{getDifficultyLabel(challenge.difficulty)}]
|
||||||
|
</span>
|
||||||
|
<span className="text-neon-400 font-medium">
|
||||||
|
+{challenge.points} очков
|
||||||
|
</span>
|
||||||
|
{challenge.estimated_time && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
~{challenge.estimated_time} мин
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Задание загружается...
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-display font-bold text-white">
|
||||||
|
Привет, {user?.nickname || 'Игрок'}!
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{isMonitoring ? (currentGame ? `Играет: ${currentGame}` : 'Мониторинг активен') : 'Мониторинг выключен'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{currentGame && isMonitoring && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/10 border border-green-500/30 rounded-full">
|
||||||
|
<div className="live-indicator" />
|
||||||
|
<span className="text-xs text-green-400 font-medium truncate max-w-[100px]">{currentGame}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleMonitoring}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
isMonitoring
|
||||||
|
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||||
|
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||||
|
}`}
|
||||||
|
title={isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг'}
|
||||||
|
>
|
||||||
|
{isMonitoring ? <Square className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<GlassCard variant="neon" className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-neon-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400">Сегодня</p>
|
||||||
|
<p className="text-lg font-bold text-white">{formatTime(todayTime)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard variant="default" className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
|
||||||
|
<Trophy className="w-5 h-5 text-accent-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400">За неделю</p>
|
||||||
|
<p className="text-lg font-bold text-white">{formatTime(weekTime)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current challenge */}
|
||||||
|
<GlassCard variant="dark" className="border border-neon-500/20">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Target className="w-5 h-5 text-neon-500" />
|
||||||
|
<h2 className="font-semibold text-white">Текущий челлендж</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon selector */}
|
||||||
|
{marathons.length > 1 && (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedMarathonId || ''}
|
||||||
|
onChange={(e) => selectMarathon(Number(e.target.value))}
|
||||||
|
className="appearance-none bg-dark-800 border border-dark-600 rounded-lg px-3 py-1.5 pr-8 text-xs text-gray-300 focus:outline-none focus:border-neon-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
{marathons.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title.length > 30 ? m.title.substring(0, 30) + '...' : m.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon title for single marathon */}
|
||||||
|
{marathons.length === 1 && selectedMarathon && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{selectedMarathon.title}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderCurrentChallenge()}
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Tracked games */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-neon-500" />
|
||||||
|
Отслеживаемые игры
|
||||||
|
</h2>
|
||||||
|
<Link to="/games">
|
||||||
|
<NeonButton variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />}>
|
||||||
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trackedGames.length === 0 ? (
|
||||||
|
<GlassCard variant="dark" className="text-center py-8">
|
||||||
|
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Нет отслеживаемых игр
|
||||||
|
</p>
|
||||||
|
<Link to="/games">
|
||||||
|
<NeonButton variant="secondary" size="sm">
|
||||||
|
Добавить игру
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</GlassCard>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{trackedGames.slice(0, 4).map((game) => (
|
||||||
|
<GlassCard
|
||||||
|
key={game.id}
|
||||||
|
variant="default"
|
||||||
|
hover
|
||||||
|
className="p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{currentGame === game.name && <div className="live-indicator" />}
|
||||||
|
<p className="text-sm font-medium text-white truncate flex-1">
|
||||||
|
{game.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{formatTime(game.totalTime)}
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trackedGames.length > 4 && (
|
||||||
|
<Link to="/games" className="block mt-2">
|
||||||
|
<NeonButton variant="ghost" size="sm" className="w-full">
|
||||||
|
Показать все ({trackedGames.length})
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
298
desktop/src/renderer/pages/GamesPage.tsx
Normal file
298
desktop/src/renderer/pages/GamesPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Gamepad2, Plus, Trash2, Search, FolderOpen, Cpu, RefreshCw, Loader2 } from 'lucide-react'
|
||||||
|
import { useTrackingStore } from '../store/tracking'
|
||||||
|
import { GlassCard } from '../components/ui/GlassCard'
|
||||||
|
import { NeonButton } from '../components/ui/NeonButton'
|
||||||
|
import { Input } from '../components/ui/Input'
|
||||||
|
import type { TrackedProcess } from '@shared/types'
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
const remainingMinutes = minutes % 60
|
||||||
|
return `${hours}ч ${remainingMinutes}м`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}м`
|
||||||
|
} else {
|
||||||
|
return `${seconds}с`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System processes to filter out
|
||||||
|
const SYSTEM_PROCESSES = new Set([
|
||||||
|
'svchost', 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
|
||||||
|
'dwm', 'explorer', 'taskhost', 'conhost', 'spoolsv', 'searchhost',
|
||||||
|
'runtimebroker', 'sihost', 'fontdrvhost', 'ctfmon', 'dllhost',
|
||||||
|
'securityhealthservice', 'searchindexer', 'audiodg', 'wudfhost',
|
||||||
|
'system', 'registry', 'idle', 'memory compression', 'ntoskrnl',
|
||||||
|
'shellexperiencehost', 'startmenuexperiencehost', 'applicationframehost',
|
||||||
|
'systemsettings', 'textinputhost', 'searchui', 'cortana', 'lockapp',
|
||||||
|
'windowsinternal', 'taskhostw', 'wmiprvse', 'msiexec', 'trustedinstaller',
|
||||||
|
'tiworker', 'smartscreen', 'securityhealthsystray', 'sgrmbroker',
|
||||||
|
'gamebarpresencewriter', 'gamebar', 'gamebarftserver',
|
||||||
|
'microsoftedge', 'msedge', 'chrome', 'firefox', 'opera', 'brave',
|
||||||
|
'discord', 'slack', 'teams', 'zoom', 'skype',
|
||||||
|
'powershell', 'cmd', 'windowsterminal', 'code', 'devenv',
|
||||||
|
'node', 'npm', 'electron', 'vite'
|
||||||
|
])
|
||||||
|
|
||||||
|
export function GamesPage() {
|
||||||
|
const { trackedGames, currentGame, loadTrackedGames, addGame, removeGame } = useTrackingStore()
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [addMode, setAddMode] = useState<'process' | 'manual'>('process')
|
||||||
|
const [manualGame, setManualGame] = useState({ name: '', executableName: '' })
|
||||||
|
const [processes, setProcesses] = useState<TrackedProcess[]>([])
|
||||||
|
const [isLoadingProcesses, setIsLoadingProcesses] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTrackedGames()
|
||||||
|
}, [loadTrackedGames])
|
||||||
|
|
||||||
|
const loadProcesses = async () => {
|
||||||
|
setIsLoadingProcesses(true)
|
||||||
|
try {
|
||||||
|
const procs = await window.electronAPI.getRunningProcesses()
|
||||||
|
// Filter out system processes and already tracked games
|
||||||
|
const filtered = procs.filter(p => {
|
||||||
|
const name = p.name.toLowerCase().replace('.exe', '')
|
||||||
|
return !SYSTEM_PROCESSES.has(name) &&
|
||||||
|
!trackedGames.some(tg =>
|
||||||
|
tg.executableName.toLowerCase().replace('.exe', '') === name
|
||||||
|
)
|
||||||
|
})
|
||||||
|
setProcesses(filtered)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load processes:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProcesses(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAddModal && addMode === 'process') {
|
||||||
|
loadProcesses()
|
||||||
|
}
|
||||||
|
}, [showAddModal, addMode])
|
||||||
|
|
||||||
|
const filteredProcesses = processes.filter(
|
||||||
|
(proc) =>
|
||||||
|
proc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(proc.windowTitle && proc.windowTitle.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddProcess = async (process: TrackedProcess) => {
|
||||||
|
const name = process.windowTitle || process.displayName || process.name.replace('.exe', '')
|
||||||
|
await addGame({
|
||||||
|
id: `proc_${Date.now()}`,
|
||||||
|
name: name,
|
||||||
|
executableName: process.name,
|
||||||
|
executablePath: process.executablePath,
|
||||||
|
})
|
||||||
|
setShowAddModal(false)
|
||||||
|
setSearchQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddManualGame = async () => {
|
||||||
|
if (!manualGame.name || !manualGame.executableName) return
|
||||||
|
|
||||||
|
await addGame({
|
||||||
|
id: `manual_${Date.now()}`,
|
||||||
|
name: manualGame.name,
|
||||||
|
executableName: manualGame.executableName,
|
||||||
|
})
|
||||||
|
setShowAddModal(false)
|
||||||
|
setManualGame({ name: '', executableName: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveGame = async (gameId: string) => {
|
||||||
|
if (confirm('Удалить игру из отслеживания?')) {
|
||||||
|
await removeGame(gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-display font-bold text-white flex items-center gap-2">
|
||||||
|
<Gamepad2 className="w-6 h-6 text-neon-500" />
|
||||||
|
Игры
|
||||||
|
</h1>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Games list */}
|
||||||
|
{trackedGames.length === 0 ? (
|
||||||
|
<GlassCard variant="dark" className="text-center py-12">
|
||||||
|
<Gamepad2 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Нет игр</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Добавьте игры для отслеживания времени
|
||||||
|
</p>
|
||||||
|
<NeonButton onClick={() => setShowAddModal(true)}>
|
||||||
|
Добавить игру
|
||||||
|
</NeonButton>
|
||||||
|
</GlassCard>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trackedGames.map((game) => (
|
||||||
|
<GlassCard
|
||||||
|
key={game.id}
|
||||||
|
variant="default"
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{currentGame === game.name && <div className="live-indicator flex-shrink-0" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{game.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{formatTime(game.totalTime)} наиграно
|
||||||
|
{game.steamAppId && ' • Steam'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveGame(game.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-400 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add game modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/80 backdrop-blur-sm">
|
||||||
|
<GlassCard variant="dark" className="w-full max-w-sm mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Добавить игру</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="p-1 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex gap-1 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setAddMode('process')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
addMode === 'process'
|
||||||
|
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
|
Процессы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddMode('manual')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
addMode === 'manual'
|
||||||
|
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
|
Вручную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addMode === 'process' && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск процесса..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
icon={<Search className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadProcesses}
|
||||||
|
disabled={isLoadingProcesses}
|
||||||
|
className="p-2.5 bg-dark-700 border border-dark-600 rounded-lg text-gray-400 hover:text-white hover:border-neon-500/50 transition-colors disabled:opacity-50"
|
||||||
|
title="Обновить список"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoadingProcesses ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Запустите игру и нажмите обновить
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2 overflow-y-auto max-h-52">
|
||||||
|
{isLoadingProcesses ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filteredProcesses.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-400 text-sm py-4">
|
||||||
|
{processes.length === 0 ? 'Нет подходящих процессов' : 'Ничего не найдено'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredProcesses.slice(0, 20).map((proc) => (
|
||||||
|
<button
|
||||||
|
key={proc.id}
|
||||||
|
onClick={() => handleAddProcess(proc)}
|
||||||
|
className="w-full flex items-start gap-3 p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Cpu className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-white truncate">
|
||||||
|
{proc.windowTitle || proc.displayName || proc.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{proc.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addMode === 'manual' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Название игры"
|
||||||
|
placeholder="Например: Elden Ring"
|
||||||
|
value={manualGame.name}
|
||||||
|
onChange={(e) => setManualGame({ ...manualGame, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Имя процесса (exe)"
|
||||||
|
placeholder="Например: eldenring.exe"
|
||||||
|
value={manualGame.executableName}
|
||||||
|
onChange={(e) => setManualGame({ ...manualGame, executableName: e.target.value })}
|
||||||
|
/>
|
||||||
|
<NeonButton
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleAddManualGame}
|
||||||
|
disabled={!manualGame.name || !manualGame.executableName}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
desktop/src/renderer/pages/LoginPage.tsx
Normal file
183
desktop/src/renderer/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Gamepad2, User, Lock, X, Minus, Shield, ArrowLeft } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '../store/auth'
|
||||||
|
import { NeonButton } from '../components/ui/NeonButton'
|
||||||
|
import { Input } from '../components/ui/Input'
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login, verify2fa, isLoading, error, clearError, requires2fa, reset2fa } = useAuthStore()
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const [twoFactorCode, setTwoFactorCode] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const success = await login(formData.login, formData.password)
|
||||||
|
if (success) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle2faSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const success = await verify2fa(twoFactorCode)
|
||||||
|
if (success) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
reset2fa()
|
||||||
|
setTwoFactorCode('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-900 flex flex-col">
|
||||||
|
{/* Custom title bar */}
|
||||||
|
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gamepad2 className="w-4 h-4 text-neon-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => window.electronAPI.minimizeToTray()}
|
||||||
|
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.electronAPI.quitApp()}
|
||||||
|
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-neon-500/10 border border-neon-500/30 mb-4">
|
||||||
|
{requires2fa ? (
|
||||||
|
<Shield className="w-8 h-8 text-neon-500" />
|
||||||
|
) : (
|
||||||
|
<Gamepad2 className="w-8 h-8 text-neon-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-white mb-2">
|
||||||
|
{requires2fa ? 'Подтверждение' : 'Game Marathon'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{requires2fa
|
||||||
|
? 'Введите код из Telegram'
|
||||||
|
: 'Войдите в свой аккаунт'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requires2fa ? (
|
||||||
|
/* 2FA Form */
|
||||||
|
<form onSubmit={handle2faSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Код подтверждения"
|
||||||
|
type="text"
|
||||||
|
value={twoFactorCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||||
|
clearError()
|
||||||
|
}}
|
||||||
|
icon={<Shield className="w-5 h-5" />}
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={twoFactorCode.length !== 6}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</NeonButton>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* Login Form */
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Логин"
|
||||||
|
type="text"
|
||||||
|
value={formData.login}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({ ...formData, login: e.target.value })
|
||||||
|
clearError()
|
||||||
|
}}
|
||||||
|
icon={<User className="w-5 h-5" />}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
clearError()
|
||||||
|
}}
|
||||||
|
icon={<Lock className="w-5 h-5" />}
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{!requires2fa && (
|
||||||
|
<p className="text-center text-gray-500 text-xs mt-6">
|
||||||
|
Нет аккаунта? Зарегистрируйтесь на сайте
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal file
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Settings, Power, Monitor, Clock, Globe, LogOut, Download, RefreshCw, Check, AlertCircle } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../store/auth'
|
||||||
|
import { GlassCard } from '../components/ui/GlassCard'
|
||||||
|
import { NeonButton } from '../components/ui/NeonButton'
|
||||||
|
import { Input } from '../components/ui/Input'
|
||||||
|
import type { AppSettings } from '@shared/types'
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [appVersion, setAppVersion] = useState('')
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'not-available' | 'error'>('idle')
|
||||||
|
const [updateVersion, setUpdateVersion] = useState('')
|
||||||
|
const [updateError, setUpdateError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI.getSettings().then(setSettings)
|
||||||
|
window.electronAPI.getAppVersion().then(setAppVersion)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCheckForUpdates = async () => {
|
||||||
|
setUpdateStatus('checking')
|
||||||
|
setUpdateError('')
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.checkForUpdates()
|
||||||
|
if (result.error) {
|
||||||
|
setUpdateStatus('error')
|
||||||
|
setUpdateError(result.error)
|
||||||
|
} else if (result.available) {
|
||||||
|
setUpdateStatus('available')
|
||||||
|
setUpdateVersion(result.version || '')
|
||||||
|
} else {
|
||||||
|
setUpdateStatus('not-available')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setUpdateStatus('error')
|
||||||
|
setUpdateError('Ошибка проверки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstallUpdate = () => {
|
||||||
|
window.electronAPI.installUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (key: keyof AppSettings, value: boolean) => {
|
||||||
|
if (!settings) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await window.electronAPI.saveSettings({ [key]: value })
|
||||||
|
setSettings({ ...settings, [key]: value })
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApiUrlChange = async (url: string) => {
|
||||||
|
if (!settings) return
|
||||||
|
setSettings({ ...settings, apiUrl: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApiUrlSave = async () => {
|
||||||
|
if (!settings) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await window.electronAPI.saveSettings({ apiUrl: settings.apiUrl })
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-6 h-6 text-neon-500" />
|
||||||
|
<h1 className="text-xl font-display font-bold text-white">Настройки</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<GlassCard variant="neon" className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<span className="text-lg font-bold text-neon-400">
|
||||||
|
{user?.nickname?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{user?.nickname}</p>
|
||||||
|
<p className="text-xs text-gray-400">@{user?.login}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NeonButton variant="ghost" size="sm" icon={<LogOut className="w-4 h-4" />} onClick={handleLogout}>
|
||||||
|
Выйти
|
||||||
|
</NeonButton>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Auto-launch */}
|
||||||
|
<GlassCard variant="dark" className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
|
||||||
|
<Power className="w-5 h-5 text-accent-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Автозапуск</p>
|
||||||
|
<p className="text-xs text-gray-400">Запускать при старте Windows</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoLaunch}
|
||||||
|
onChange={(e) => handleToggle('autoLaunch', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
|
||||||
|
</label>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Minimize to tray */}
|
||||||
|
<GlassCard variant="dark" className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
|
||||||
|
<Monitor className="w-5 h-5 text-neon-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Сворачивать в трей</p>
|
||||||
|
<p className="text-xs text-gray-400">При закрытии скрывать в трей</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.minimizeToTray}
|
||||||
|
onChange={(e) => handleToggle('minimizeToTray', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
|
||||||
|
</label>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Tracking interval */}
|
||||||
|
<GlassCard variant="dark">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-pink-500/10 flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-pink-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Интервал проверки</p>
|
||||||
|
<p className="text-xs text-gray-400">Как часто проверять процессы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={settings.trackingInterval}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value)
|
||||||
|
setSettings({ ...settings, trackingInterval: value })
|
||||||
|
window.electronAPI.saveSettings({ trackingInterval: value })
|
||||||
|
}}
|
||||||
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-4 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value={3000}>3 секунды</option>
|
||||||
|
<option value={5000}>5 секунд</option>
|
||||||
|
<option value={10000}>10 секунд</option>
|
||||||
|
<option value={30000}>30 секунд</option>
|
||||||
|
</select>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Updates */}
|
||||||
|
<GlassCard variant="dark">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||||
|
<Download className="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Обновления</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{updateStatus === 'checking' && 'Проверка...'}
|
||||||
|
{updateStatus === 'available' && `Доступна v${updateVersion}`}
|
||||||
|
{updateStatus === 'not-available' && 'Актуальная версия'}
|
||||||
|
{updateStatus === 'error' && (updateError || 'Ошибка')}
|
||||||
|
{updateStatus === 'idle' && `Текущая версия: v${appVersion}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{updateStatus === 'available' ? (
|
||||||
|
<NeonButton size="sm" onClick={handleInstallUpdate}>
|
||||||
|
Установить
|
||||||
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleCheckForUpdates}
|
||||||
|
disabled={updateStatus === 'checking'}
|
||||||
|
className="p-2 rounded-lg bg-dark-700 text-gray-400 hover:text-white hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateStatus === 'checking' ? (
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
) : updateStatus === 'not-available' ? (
|
||||||
|
<Check className="w-5 h-5 text-green-500" />
|
||||||
|
) : updateStatus === 'error' ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* API URL (for developers) */}
|
||||||
|
<GlassCard variant="dark">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-500/10 flex items-center justify-center">
|
||||||
|
<Globe className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">API URL</p>
|
||||||
|
<p className="text-xs text-gray-400">Для разработки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={settings.apiUrl}
|
||||||
|
onChange={(e) => handleApiUrlChange(e.target.value)}
|
||||||
|
placeholder="http://localhost:8000/api/v1"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<NeonButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApiUrlSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<p className="text-center text-gray-500 text-xs pt-4">
|
||||||
|
Game Marathon Tracker v{appVersion || '...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
desktop/src/renderer/splash.html
Normal file
104
desktop/src/renderer/splash.html
Normal 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>
|
||||||
149
desktop/src/renderer/store/auth.ts
Normal file
149
desktop/src/renderer/store/auth.ts
Normal 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 }),
|
||||||
|
}))
|
||||||
123
desktop/src/renderer/store/marathon.ts
Normal file
123
desktop/src/renderer/store/marathon.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
81
desktop/src/renderer/store/tracking.ts
Normal file
81
desktop/src/renderer/store/tracking.ts
Normal 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
54
desktop/src/renderer/vite-env.d.ts
vendored
Normal 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
226
desktop/src/shared/types.ts
Normal 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
147
desktop/tailwind.config.js
Normal 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
26
desktop/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
18
desktop/tsconfig.main.json
Normal file
18
desktop/tsconfig.main.json
Normal 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
22
desktop/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
<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">
|
{/* Points - calculated from tracked time if available */}
|
||||||
+{currentAssignment.playthrough_info?.points} очков
|
{currentAssignment.tracked_time_minutes !== undefined && currentAssignment.tracked_time_minutes > 0 ? (
|
||||||
</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">
|
||||||
|
~{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">
|
||||||
|
+{currentAssignment.playthrough_info?.points} очков
|
||||||
|
</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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user