Time tracker app
This commit is contained in:
@@ -15,6 +15,7 @@ from app.models import (
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
||||
TrackTimeRequest,
|
||||
)
|
||||
from app.schemas.game import PlaythroughInfo
|
||||
from app.services.points import PointsService
|
||||
@@ -589,7 +590,14 @@ async def complete_assignment(
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
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)
|
||||
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)
|
||||
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Drop current assignment"""
|
||||
|
||||
@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
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_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -32,6 +32,7 @@ class Assignment(Base):
|
||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ from app.schemas.assignment import (
|
||||
CompleteBonusAssignment,
|
||||
BonusCompleteResult,
|
||||
AvailableGamesCount,
|
||||
TrackTimeRequest,
|
||||
)
|
||||
from app.schemas.activity import (
|
||||
ActivityResponse,
|
||||
|
||||
@@ -52,6 +52,7 @@ class AssignmentResponse(BaseModel):
|
||||
proof_comment: str | None = None
|
||||
points_earned: int
|
||||
streak_at_completion: int | None = None
|
||||
tracked_time_minutes: int = 0 # Time tracked by desktop app
|
||||
started_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||
@@ -62,6 +63,11 @@ class AssignmentResponse(BaseModel):
|
||||
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):
|
||||
assignment_id: int
|
||||
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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user