Initial commit
This commit is contained in:
21
src/main/autostart.ts
Normal file
21
src/main/autostart.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
const HIDDEN_FLAG = '--hidden'
|
||||
|
||||
export function setAutostart(enabled: boolean): void {
|
||||
if (process.platform !== 'win32') return
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
path: process.execPath,
|
||||
args: [HIDDEN_FLAG]
|
||||
})
|
||||
}
|
||||
|
||||
export function isAutostartEnabled(): boolean {
|
||||
if (process.platform !== 'win32') return false
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
}
|
||||
|
||||
export function wasStartedHidden(): boolean {
|
||||
return process.argv.includes(HIDDEN_FLAG) || app.getLoginItemSettings().wasOpenedAsHidden
|
||||
}
|
||||
218
src/main/games/dota2.ts
Normal file
218
src/main/games/dota2.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import type { GameProvider, ProviderEventHandler } from './provider'
|
||||
import { findGameInstall } from './steam'
|
||||
import { registerGsiRoute, getGsiBaseUrl, getGsiPort } from './gsi-server'
|
||||
import {
|
||||
ensureLaunchOption,
|
||||
ensureLaunchOptionRemoved,
|
||||
isLaunchOptionPresent,
|
||||
isSteamRunning
|
||||
} from './steam-launch-options'
|
||||
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
|
||||
|
||||
const APP_ID = '570'
|
||||
const INSTALL_DIR = 'dota 2 beta'
|
||||
const CFG_NAME = 'gamestate_integration_exercise_reminder.cfg'
|
||||
const ROUTE = '/dota2'
|
||||
const LAUNCH_OPTION = '-gamestateintegration'
|
||||
|
||||
type DotaGsi = {
|
||||
provider?: { name?: string }
|
||||
map?: {
|
||||
game_state?: string
|
||||
win_team?: 'radiant' | 'dire' | 'none'
|
||||
game_time?: number
|
||||
clock_time?: number
|
||||
}
|
||||
player?: {
|
||||
kills?: number
|
||||
deaths?: number
|
||||
assists?: number
|
||||
last_hits?: number
|
||||
denies?: number
|
||||
team_name?: 'radiant' | 'dire'
|
||||
steamid?: string
|
||||
}
|
||||
}
|
||||
|
||||
function tokenStorePath(): string {
|
||||
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
|
||||
}
|
||||
|
||||
function getOrCreateToken(): string {
|
||||
const p = tokenStorePath()
|
||||
if (existsSync(p)) {
|
||||
const v = readFileSync(p, 'utf-8').trim()
|
||||
if (v) return v
|
||||
}
|
||||
const token = randomBytes(16).toString('hex')
|
||||
writeFileSync(p, token, 'utf-8')
|
||||
return token
|
||||
}
|
||||
|
||||
function cfgDir(installPath: string): string {
|
||||
return join(installPath, 'game', 'dota', 'cfg', 'gamestate_integration')
|
||||
}
|
||||
|
||||
function cfgPath(installPath: string): string {
|
||||
return join(cfgDir(installPath), CFG_NAME)
|
||||
}
|
||||
|
||||
function buildCfg(token: string): string {
|
||||
const uri = `${getGsiBaseUrl()}${ROUTE}`
|
||||
return `"Exercise Reminder Integration"
|
||||
{
|
||||
"uri" "${uri}"
|
||||
"timeout" "5.0"
|
||||
"buffer" "0.1"
|
||||
"throttle" "0.5"
|
||||
"heartbeat" "10.0"
|
||||
"auth"
|
||||
{
|
||||
"token" "${token}"
|
||||
}
|
||||
"data"
|
||||
{
|
||||
"provider" "1"
|
||||
"map" "1"
|
||||
"player" "1"
|
||||
"hero" "1"
|
||||
"abilities" "0"
|
||||
"items" "0"
|
||||
"events" "0"
|
||||
"buildings" "0"
|
||||
"league" "0"
|
||||
"draft" "0"
|
||||
"wearables" "0"
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export class Dota2Provider implements GameProvider {
|
||||
readonly id: GameId = 'dota2'
|
||||
readonly displayName = 'Dota 2'
|
||||
|
||||
private installPath: string | undefined
|
||||
private unregister: (() => void) | undefined
|
||||
private emit: ProviderEventHandler | undefined
|
||||
private prevState: string | undefined
|
||||
private latest: DotaGsi | undefined
|
||||
private lastMatchEndAt = 0
|
||||
private token: string = getOrCreateToken()
|
||||
|
||||
async detect(): Promise<GameStatus> {
|
||||
const path = await findGameInstall(APP_ID, INSTALL_DIR)
|
||||
this.installPath = path
|
||||
const integrationActive = !!path && existsSync(cfgPath(path))
|
||||
let launchOptionStatus: LaunchOptionStatus = 'not_needed'
|
||||
let steamRunning: boolean | undefined
|
||||
if (integrationActive) {
|
||||
const present = await isLaunchOptionPresent(APP_ID, LAUNCH_OPTION)
|
||||
if (present) launchOptionStatus = 'applied'
|
||||
else {
|
||||
steamRunning = await isSteamRunning()
|
||||
launchOptionStatus = steamRunning ? 'queued' : 'queued'
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.displayName,
|
||||
installed: !!path,
|
||||
installPath: path,
|
||||
integrationActive,
|
||||
launchOption: LAUNCH_OPTION,
|
||||
launchOptionStatus,
|
||||
steamRunning,
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
async install(): Promise<void> {
|
||||
if (!this.installPath) {
|
||||
const status = await this.detect()
|
||||
if (!status.installPath) throw new Error('Dota 2 не найдена в Steam-библиотеках')
|
||||
}
|
||||
const dir = cfgDir(this.installPath!)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(cfgPath(this.installPath!), buildCfg(this.token), 'utf-8')
|
||||
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
await ensureLaunchOptionRemoved(APP_ID, LAUNCH_OPTION)
|
||||
if (!this.installPath) return
|
||||
const p = cfgPath(this.installPath)
|
||||
if (existsSync(p)) unlinkSync(p)
|
||||
}
|
||||
|
||||
async reconcile(): Promise<void> {
|
||||
if (!this.installPath) await this.detect()
|
||||
if (!this.installPath || !existsSync(cfgPath(this.installPath))) return
|
||||
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
|
||||
}
|
||||
|
||||
async start(emit: ProviderEventHandler): Promise<void> {
|
||||
this.emit = emit
|
||||
this.unregister = registerGsiRoute(ROUTE, (payload) => this.handle(payload as DotaGsi))
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.unregister?.()
|
||||
this.unregister = undefined
|
||||
this.emit = undefined
|
||||
this.prevState = undefined
|
||||
this.latest = undefined
|
||||
}
|
||||
|
||||
private handle(g: DotaGsi): void {
|
||||
// Track latest snapshot so we have stats when the transition fires.
|
||||
if (g.player || g.map) this.latest = { ...this.latest, ...g, player: { ...this.latest?.player, ...g.player }, map: { ...this.latest?.map, ...g.map } }
|
||||
|
||||
const state = g.map?.game_state ?? this.latest?.map?.game_state
|
||||
if (!state) return
|
||||
|
||||
const prev = this.prevState
|
||||
this.prevState = state
|
||||
|
||||
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
|
||||
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
|
||||
const now = Date.now()
|
||||
if (now - this.lastMatchEndAt < 30_000) return
|
||||
this.lastMatchEndAt = now
|
||||
|
||||
const p = this.latest?.player ?? {}
|
||||
const m = this.latest?.map ?? {}
|
||||
const playerTeam = p.team_name
|
||||
const winner = m.win_team
|
||||
const won =
|
||||
winner === 'radiant' || winner === 'dire'
|
||||
? playerTeam === winner
|
||||
: undefined
|
||||
const durationMs = (m.game_time ?? m.clock_time ?? 0) * 1000
|
||||
|
||||
this.emit?.({
|
||||
type: 'match_end',
|
||||
payload: {
|
||||
durationMs,
|
||||
won,
|
||||
stats: {
|
||||
deaths: p.deaths ?? 0,
|
||||
kills: p.kills ?? 0,
|
||||
assists: p.assists ?? 0,
|
||||
last_hits: p.last_hits ?? 0,
|
||||
denies: p.denies ?? 0,
|
||||
duration_min: Math.floor(durationMs / 60_000)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dota2DebugInfo(): { port: number; route: string } {
|
||||
return { port: getGsiPort(), route: ROUTE }
|
||||
}
|
||||
73
src/main/games/gsi-server.ts
Normal file
73
src/main/games/gsi-server.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
|
||||
export type GsiHandler = (payload: unknown, headers: Record<string, string | string[] | undefined>) => void
|
||||
|
||||
const PORT = 4701
|
||||
|
||||
let server: Server | null = null
|
||||
const handlers: Map<string, GsiHandler> = new Map()
|
||||
|
||||
function getBody(req: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c) => chunks.push(c as Buffer))
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const route = (req.url ?? '/').split('?')[0]
|
||||
const handler = handlers.get(route)
|
||||
if (!handler) {
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await getBody(req)
|
||||
const text = body.toString('utf-8')
|
||||
const payload = text.length > 0 ? JSON.parse(text) : {}
|
||||
handler(payload, req.headers)
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/plain')
|
||||
res.end('ok')
|
||||
} catch (err) {
|
||||
res.statusCode = 500
|
||||
res.end(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGsiServer(): Promise<void> {
|
||||
if (server) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createServer(onRequest)
|
||||
server.once('error', reject)
|
||||
server.listen(PORT, '127.0.0.1', () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
export function stopGsiServer(): void {
|
||||
if (server) {
|
||||
server.close()
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGsiRoute(route: string, handler: GsiHandler): () => void {
|
||||
handlers.set(route, handler)
|
||||
return () => handlers.delete(route)
|
||||
}
|
||||
|
||||
export function getGsiBaseUrl(): string {
|
||||
return `http://127.0.0.1:${PORT}`
|
||||
}
|
||||
|
||||
export function getGsiPort(): number {
|
||||
return PORT
|
||||
}
|
||||
20
src/main/games/provider.ts
Normal file
20
src/main/games/provider.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { GameId, GameStat, GameStatus } from '@shared/types'
|
||||
|
||||
export type MatchEndPayload = {
|
||||
durationMs: number
|
||||
won?: boolean
|
||||
stats: Partial<Record<GameStat, number>>
|
||||
}
|
||||
|
||||
export type ProviderEventHandler = (event: { type: 'match_end'; payload: MatchEndPayload }) => void
|
||||
|
||||
export interface GameProvider {
|
||||
readonly id: GameId
|
||||
readonly displayName: string
|
||||
detect(): Promise<GameStatus>
|
||||
install(): Promise<void>
|
||||
uninstall(): Promise<void>
|
||||
start(emit: ProviderEventHandler): Promise<void>
|
||||
stop(): Promise<void>
|
||||
reconcile?(): Promise<void>
|
||||
}
|
||||
148
src/main/games/registry.ts
Normal file
148
src/main/games/registry.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { GameProvider, MatchEndPayload } from './provider'
|
||||
import { Dota2Provider } from './dota2'
|
||||
import { startGsiServer, stopGsiServer } from './gsi-server'
|
||||
import { onLaunchOptionsApplied } from './steam-launch-options'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type {
|
||||
Challenge,
|
||||
ChallengeResult,
|
||||
GameId,
|
||||
GameStatus,
|
||||
MatchSummary
|
||||
} from '@shared/types'
|
||||
import { STAT_LABELS } from '@shared/types'
|
||||
import { getChallenges, getGamesEnabled } from '../store'
|
||||
import { fireMatchSummary } from '../notifications'
|
||||
|
||||
const providers: Record<GameId, GameProvider> = {
|
||||
dota2: new Dota2Provider()
|
||||
}
|
||||
|
||||
let running = false
|
||||
|
||||
async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<void> {
|
||||
const provider = providers[gameId]
|
||||
const challenges = getChallenges().filter(
|
||||
(c) => c.gameId === gameId && c.enabled
|
||||
)
|
||||
const results: ChallengeResult[] = []
|
||||
for (const ch of challenges) {
|
||||
const statValue = payload.stats[ch.stat] ?? 0
|
||||
const reps = Math.round(statValue * ch.multiplier)
|
||||
if (reps <= 0) continue
|
||||
results.push({
|
||||
challengeId: ch.id,
|
||||
name: ch.name,
|
||||
icon: ch.icon,
|
||||
exerciseName: ch.exerciseName,
|
||||
reps,
|
||||
statValue,
|
||||
statLabel: STAT_LABELS[ch.stat]
|
||||
})
|
||||
}
|
||||
if (results.length === 0) return
|
||||
|
||||
const summary: MatchSummary = {
|
||||
gameId,
|
||||
gameName: provider.displayName,
|
||||
durationMs: payload.durationMs,
|
||||
won: payload.won,
|
||||
results
|
||||
}
|
||||
fireMatchSummary(summary)
|
||||
}
|
||||
|
||||
export async function startGamesRegistry(): Promise<void> {
|
||||
if (running) return
|
||||
running = true
|
||||
try {
|
||||
await startGsiServer()
|
||||
} catch (err) {
|
||||
console.error('GSI server failed to start:', err)
|
||||
return
|
||||
}
|
||||
|
||||
onLaunchOptionsApplied(() => {
|
||||
// When Steam closed and we flushed queued ops, refresh statuses.
|
||||
void listGamesStatus().then((games) => broadcastGames(games))
|
||||
})
|
||||
|
||||
const enabled = getGamesEnabled()
|
||||
for (const id of Object.keys(providers) as GameId[]) {
|
||||
const provider = providers[id]
|
||||
// Reconcile launch options on boot (handles cases where Steam
|
||||
// overwrote our flag, or user removed it manually).
|
||||
try {
|
||||
await provider.reconcile?.()
|
||||
} catch (err) {
|
||||
console.error('reconcile failed for', id, err)
|
||||
}
|
||||
if (!enabled[id]) continue
|
||||
await provider.start((e) => {
|
||||
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopGamesRegistry(): Promise<void> {
|
||||
if (!running) return
|
||||
running = false
|
||||
for (const id of Object.keys(providers) as GameId[]) {
|
||||
await providers[id].stop()
|
||||
}
|
||||
stopGsiServer()
|
||||
}
|
||||
|
||||
export async function listGamesStatus(): Promise<GameStatus[]> {
|
||||
const enabled = getGamesEnabled()
|
||||
const out: GameStatus[] = []
|
||||
for (const id of Object.keys(providers) as GameId[]) {
|
||||
const s = await providers[id].detect()
|
||||
out.push({ ...s, enabled: !!enabled[id] })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function installGame(id: GameId): Promise<GameStatus> {
|
||||
const provider = providers[id]
|
||||
await provider.install()
|
||||
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
|
||||
}
|
||||
|
||||
export async function uninstallGame(id: GameId): Promise<GameStatus> {
|
||||
const provider = providers[id]
|
||||
await provider.uninstall()
|
||||
await provider.stop()
|
||||
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
|
||||
}
|
||||
|
||||
export async function toggleGame(id: GameId, enabled: boolean): Promise<void> {
|
||||
const provider = providers[id]
|
||||
if (enabled) {
|
||||
await provider.start((e) => {
|
||||
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
|
||||
})
|
||||
} else {
|
||||
await provider.stop()
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastGames(games: GameStatus[]): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtGamesChanged, games)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate a match-end for debugging (called from IPC in dev).
|
||||
export function simulateMatchEnd(id: GameId, stats: Partial<Record<string, number>>): void {
|
||||
void onMatchEnd(id, {
|
||||
durationMs: (stats.duration_min ?? 35) * 60_000,
|
||||
won: stats.won === 1,
|
||||
stats: stats as Partial<Record<import('@shared/types').GameStat, number>>
|
||||
})
|
||||
}
|
||||
|
||||
export function getProviders(): Record<GameId, GameProvider> {
|
||||
return providers
|
||||
}
|
||||
310
src/main/games/steam-launch-options.ts
Normal file
310
src/main/games/steam-launch-options.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { parseVdf, stringifyVdf, type VdfNode } from './vdf'
|
||||
import { getSteamPath } from './steam'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export async function isSteamRunning(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'tasklist /FI "IMAGENAME eq steam.exe" /FO CSV /NH',
|
||||
{ windowsHide: true }
|
||||
)
|
||||
return /steam\.exe/i.test(stdout)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalConfigPaths(): Promise<string[]> {
|
||||
const steamPath = await getSteamPath()
|
||||
if (!steamPath) return []
|
||||
const userdataDir = join(steamPath, 'userdata')
|
||||
if (!existsSync(userdataDir)) return []
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = readdirSync(userdataDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const paths: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (!/^\d+$/.test(entry) || entry === '0') continue
|
||||
const p = join(userdataDir, entry, 'config', 'localconfig.vdf')
|
||||
if (existsSync(p)) paths.push(p)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function findKey(node: VdfNode, target: string): string | undefined {
|
||||
const lower = target.toLowerCase()
|
||||
for (const k of Object.keys(node)) {
|
||||
if (k.toLowerCase() === lower) return k
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findCaseInsensitive(node: VdfNode, ...keys: string[]): VdfNode | undefined {
|
||||
let cur: VdfNode = node
|
||||
for (const key of keys) {
|
||||
const found: string | undefined = findKey(cur, key)
|
||||
if (!found) return undefined
|
||||
const value: string | VdfNode = cur[found]
|
||||
if (typeof value !== 'object') return undefined
|
||||
cur = value
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
function findOrCreatePath(node: VdfNode, ...keys: string[]): VdfNode {
|
||||
let cur: VdfNode = node
|
||||
for (const key of keys) {
|
||||
const found: string | undefined = findKey(cur, key)
|
||||
if (found && typeof cur[found] === 'object') {
|
||||
cur = cur[found] as VdfNode
|
||||
} else {
|
||||
if (found) delete cur[found]
|
||||
cur[key] = {}
|
||||
cur = cur[key] as VdfNode
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
function getAppNode(parsed: VdfNode, appId: string, create: boolean): VdfNode | undefined {
|
||||
if (create) {
|
||||
const apps = findOrCreatePath(
|
||||
parsed,
|
||||
'UserLocalConfigStore',
|
||||
'Software',
|
||||
'Valve',
|
||||
'Steam',
|
||||
'apps'
|
||||
)
|
||||
if (typeof apps[appId] !== 'object') apps[appId] = {}
|
||||
return apps[appId] as VdfNode
|
||||
}
|
||||
const apps = findCaseInsensitive(
|
||||
parsed,
|
||||
'UserLocalConfigStore',
|
||||
'Software',
|
||||
'Valve',
|
||||
'Steam',
|
||||
'apps'
|
||||
)
|
||||
if (!apps) return undefined
|
||||
const v = apps[appId]
|
||||
return typeof v === 'object' ? v : undefined
|
||||
}
|
||||
|
||||
function writeBackup(path: string): void {
|
||||
try {
|
||||
const bak = path + '.exr.bak'
|
||||
if (!existsSync(bak)) copyFileSync(path, bak)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, contents: string): void {
|
||||
// Write to temp then rename (atomic on Windows for same directory).
|
||||
const tmp = path + '.exr.tmp'
|
||||
writeFileSync(tmp, contents, 'utf-8')
|
||||
// fs.renameSync replaces destination atomically on Windows
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('node:fs') as typeof import('node:fs')
|
||||
fs.renameSync(tmp, path)
|
||||
}
|
||||
|
||||
function modifyLaunchOptions(
|
||||
configPath: string,
|
||||
appId: string,
|
||||
fn: (current: string) => string | null
|
||||
): boolean {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
let parsed: VdfNode
|
||||
try {
|
||||
parsed = parseVdf(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// Read existing
|
||||
const app = getAppNode(parsed, appId, false)
|
||||
const loKey = app
|
||||
? Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions')
|
||||
: undefined
|
||||
const existing = app && loKey ? String(app[loKey] ?? '') : ''
|
||||
|
||||
const next = fn(existing)
|
||||
if (next === null) return false
|
||||
if (next === existing) return true
|
||||
|
||||
const targetApp = getAppNode(parsed, appId, true)!
|
||||
// Remove any existing variant of the key to keep canonical name.
|
||||
for (const k of Object.keys(targetApp)) {
|
||||
if (k.toLowerCase() === 'launchoptions') delete targetApp[k]
|
||||
}
|
||||
if (next.length > 0) {
|
||||
targetApp['LaunchOptions'] = next
|
||||
}
|
||||
|
||||
writeBackup(configPath)
|
||||
try {
|
||||
atomicWrite(configPath, stringifyVdf(parsed))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function isLaunchOptionPresent(
|
||||
appId: string,
|
||||
option: string
|
||||
): Promise<boolean> {
|
||||
const paths = await getLocalConfigPaths()
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const raw = readFileSync(p, 'utf-8')
|
||||
const parsed = parseVdf(raw)
|
||||
const app = getAppNode(parsed, appId, false)
|
||||
if (!app) continue
|
||||
const loKey = Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions')
|
||||
if (!loKey) continue
|
||||
const value = String(app[loKey] ?? '')
|
||||
if (value.includes(option)) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function applyOptionToAllConfigs(appId: string, option: string): Promise<void> {
|
||||
const paths = await getLocalConfigPaths()
|
||||
for (const p of paths) {
|
||||
modifyLaunchOptions(p, appId, (current) => {
|
||||
if (current.includes(option)) return current
|
||||
return current.length > 0 ? `${current} ${option}` : option
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOptionFromAllConfigs(appId: string, option: string): Promise<void> {
|
||||
const paths = await getLocalConfigPaths()
|
||||
for (const p of paths) {
|
||||
modifyLaunchOptions(p, appId, (current) => {
|
||||
if (!current.includes(option)) return current
|
||||
return current
|
||||
.split(/\s+/)
|
||||
.filter((tok) => tok !== option)
|
||||
.join(' ')
|
||||
.trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pending operations queue with a watcher that polls Steam and applies on exit.
|
||||
type PendingOp = {
|
||||
appId: string
|
||||
option: string
|
||||
op: 'add' | 'remove'
|
||||
}
|
||||
|
||||
const pending: PendingOp[] = []
|
||||
let watchTimer: NodeJS.Timeout | null = null
|
||||
let onAppliedCallback: (() => void) | null = null
|
||||
|
||||
export function onLaunchOptionsApplied(cb: () => void): void {
|
||||
onAppliedCallback = cb
|
||||
}
|
||||
|
||||
export function hasPendingOperations(): boolean {
|
||||
return pending.length > 0
|
||||
}
|
||||
|
||||
function dedupePush(op: PendingOp): void {
|
||||
// Remove conflicting entries (opposite op for same option) before adding.
|
||||
for (let i = pending.length - 1; i >= 0; i--) {
|
||||
if (pending[i].appId === op.appId && pending[i].option === op.option) {
|
||||
pending.splice(i, 1)
|
||||
}
|
||||
}
|
||||
pending.push(op)
|
||||
}
|
||||
|
||||
function ensureWatcher(): void {
|
||||
if (watchTimer || pending.length === 0) return
|
||||
watchTimer = setInterval(() => {
|
||||
void tick()
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
function stopWatcher(): void {
|
||||
if (watchTimer) {
|
||||
clearInterval(watchTimer)
|
||||
watchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
if (pending.length === 0) {
|
||||
stopWatcher()
|
||||
return
|
||||
}
|
||||
if (await isSteamRunning()) return
|
||||
const toApply = [...pending]
|
||||
pending.length = 0
|
||||
for (const op of toApply) {
|
||||
if (op.op === 'add') await applyOptionToAllConfigs(op.appId, op.option)
|
||||
else await removeOptionFromAllConfigs(op.appId, op.option)
|
||||
}
|
||||
stopWatcher()
|
||||
onAppliedCallback?.()
|
||||
}
|
||||
|
||||
export type EnsureResult = 'applied' | 'queued' | 'no_user'
|
||||
|
||||
export async function ensureLaunchOption(
|
||||
appId: string,
|
||||
option: string
|
||||
): Promise<EnsureResult> {
|
||||
if (await isLaunchOptionPresent(appId, option)) return 'applied'
|
||||
const paths = await getLocalConfigPaths()
|
||||
if (paths.length === 0) return 'no_user'
|
||||
if (await isSteamRunning()) {
|
||||
dedupePush({ appId, option, op: 'add' })
|
||||
ensureWatcher()
|
||||
return 'queued'
|
||||
}
|
||||
await applyOptionToAllConfigs(appId, option)
|
||||
return (await isLaunchOptionPresent(appId, option)) ? 'applied' : 'queued'
|
||||
}
|
||||
|
||||
export async function ensureLaunchOptionRemoved(
|
||||
appId: string,
|
||||
option: string
|
||||
): Promise<EnsureResult> {
|
||||
if (!(await isLaunchOptionPresent(appId, option))) return 'applied'
|
||||
if (await isSteamRunning()) {
|
||||
dedupePush({ appId, option, op: 'remove' })
|
||||
ensureWatcher()
|
||||
return 'queued'
|
||||
}
|
||||
await removeOptionFromAllConfigs(appId, option)
|
||||
return (await isLaunchOptionPresent(appId, option)) ? 'queued' : 'applied'
|
||||
}
|
||||
102
src/main/games/steam.ts
Normal file
102
src/main/games/steam.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { parseVdf, type VdfNode } from './vdf'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
async function regQuery(key: string, valueName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`reg query "${key}" /v ${valueName}`,
|
||||
{ windowsHide: true }
|
||||
)
|
||||
const m = stdout.match(/REG_SZ\s+(.+)/)
|
||||
return m?.[1]?.trim()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSteamPath(): Promise<string | undefined> {
|
||||
const fromUser = await regQuery('HKCU\\Software\\Valve\\Steam', 'SteamPath')
|
||||
if (fromUser && existsSync(fromUser)) return fromUser.replace(/\//g, '\\')
|
||||
const fromMachine = await regQuery(
|
||||
'HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam',
|
||||
'InstallPath'
|
||||
)
|
||||
if (fromMachine && existsSync(fromMachine)) return fromMachine
|
||||
// Common fallbacks
|
||||
for (const p of [
|
||||
'C:\\Program Files (x86)\\Steam',
|
||||
'C:\\Program Files\\Steam'
|
||||
]) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type SteamLibrary = {
|
||||
path: string
|
||||
apps: Set<string>
|
||||
}
|
||||
|
||||
export async function getSteamLibraries(): Promise<SteamLibrary[]> {
|
||||
const steamPath = await getSteamPath()
|
||||
if (!steamPath) return []
|
||||
|
||||
const libsFile = join(steamPath, 'config', 'libraryfolders.vdf')
|
||||
if (!existsSync(libsFile)) {
|
||||
// Bare minimum: the install itself is a library.
|
||||
return [{ path: steamPath, apps: new Set() }]
|
||||
}
|
||||
|
||||
const raw = readFileSync(libsFile, 'utf-8')
|
||||
const parsed = parseVdf(raw)
|
||||
const libs: SteamLibrary[] = []
|
||||
|
||||
const root = (parsed['libraryfolders'] as VdfNode) ?? parsed
|
||||
for (const key of Object.keys(root)) {
|
||||
const entry = root[key]
|
||||
if (typeof entry !== 'object') continue
|
||||
const path = (entry['path'] as string) ?? ''
|
||||
if (!path) continue
|
||||
const apps = new Set<string>()
|
||||
const appsNode = entry['apps']
|
||||
if (typeof appsNode === 'object') {
|
||||
for (const appId of Object.keys(appsNode)) apps.add(appId)
|
||||
}
|
||||
libs.push({ path: path.replace(/\\\\/g, '\\'), apps })
|
||||
}
|
||||
return libs
|
||||
}
|
||||
|
||||
export async function findGameInstall(
|
||||
appId: string,
|
||||
installDirName: string
|
||||
): Promise<string | undefined> {
|
||||
const libs = await getSteamLibraries()
|
||||
for (const lib of libs) {
|
||||
// Prefer libraries that claim this app, but also probe by directory.
|
||||
const candidate = join(lib.path, 'steamapps', 'common', installDirName)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
// Fallback: scan all libs for matching appmanifest.
|
||||
for (const lib of libs) {
|
||||
const manifest = join(lib.path, 'steamapps', `appmanifest_${appId}.acf`)
|
||||
if (!existsSync(manifest)) continue
|
||||
try {
|
||||
const acf = parseVdf(readFileSync(manifest, 'utf-8'))
|
||||
const appState = acf['AppState'] as VdfNode | undefined
|
||||
const installdir = appState?.['installdir'] as string | undefined
|
||||
if (installdir) {
|
||||
const candidate = join(lib.path, 'steamapps', 'common', installdir)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
122
src/main/games/vdf.ts
Normal file
122
src/main/games/vdf.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Minimal Valve KeyValues (VDF) text parser.
|
||||
// Handles nested objects and quoted string values. Sufficient for libraryfolders.vdf.
|
||||
|
||||
export type VdfNode = { [key: string]: string | VdfNode }
|
||||
|
||||
class Cursor {
|
||||
constructor(public src: string, public pos: number = 0) {}
|
||||
peek(): string {
|
||||
return this.src[this.pos] ?? ''
|
||||
}
|
||||
next(): string {
|
||||
return this.src[this.pos++] ?? ''
|
||||
}
|
||||
eof(): boolean {
|
||||
return this.pos >= this.src.length
|
||||
}
|
||||
}
|
||||
|
||||
function skipWhitespaceAndComments(c: Cursor): void {
|
||||
for (;;) {
|
||||
while (!c.eof() && /\s/.test(c.peek())) c.next()
|
||||
if (c.peek() === '/' && c.src[c.pos + 1] === '/') {
|
||||
while (!c.eof() && c.next() !== '\n') {
|
||||
/* skip line */
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readToken(c: Cursor): string {
|
||||
skipWhitespaceAndComments(c)
|
||||
if (c.eof()) return ''
|
||||
if (c.peek() === '"') {
|
||||
c.next()
|
||||
let out = ''
|
||||
while (!c.eof()) {
|
||||
const ch = c.next()
|
||||
if (ch === '\\') {
|
||||
const next = c.next()
|
||||
if (next === 'n') out += '\n'
|
||||
else if (next === 't') out += '\t'
|
||||
else out += next
|
||||
continue
|
||||
}
|
||||
if (ch === '"') return out
|
||||
out += ch
|
||||
}
|
||||
return out
|
||||
}
|
||||
if (c.peek() === '{' || c.peek() === '}') return c.next()
|
||||
let out = ''
|
||||
while (!c.eof() && !/\s/.test(c.peek()) && c.peek() !== '{' && c.peek() !== '}') {
|
||||
out += c.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseObject(c: Cursor): VdfNode {
|
||||
const node: VdfNode = {}
|
||||
for (;;) {
|
||||
skipWhitespaceAndComments(c)
|
||||
if (c.eof()) return node
|
||||
if (c.peek() === '}') {
|
||||
c.next()
|
||||
return node
|
||||
}
|
||||
const key = readToken(c)
|
||||
if (!key) return node
|
||||
skipWhitespaceAndComments(c)
|
||||
if (c.peek() === '{') {
|
||||
c.next()
|
||||
node[key] = parseObject(c)
|
||||
} else {
|
||||
node[key] = readToken(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVdf(src: string): VdfNode {
|
||||
const c = new Cursor(src)
|
||||
const root: VdfNode = {}
|
||||
for (;;) {
|
||||
skipWhitespaceAndComments(c)
|
||||
if (c.eof()) break
|
||||
const key = readToken(c)
|
||||
if (!key) break
|
||||
skipWhitespaceAndComments(c)
|
||||
if (c.peek() === '{') {
|
||||
c.next()
|
||||
root[key] = parseObject(c)
|
||||
} else {
|
||||
root[key] = readToken(c)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function escapeVdfString(s: string): string {
|
||||
return s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
}
|
||||
|
||||
export function stringifyVdf(node: VdfNode, indent: number = 0): string {
|
||||
const pad = '\t'.repeat(indent)
|
||||
let out = ''
|
||||
for (const key of Object.keys(node)) {
|
||||
const value = node[key]
|
||||
if (typeof value === 'string') {
|
||||
out += `${pad}"${escapeVdfString(key)}"\t\t"${escapeVdfString(value)}"\n`
|
||||
} else {
|
||||
out += `${pad}"${escapeVdfString(key)}"\n${pad}{\n`
|
||||
out += stringifyVdf(value, indent + 1)
|
||||
out += `${pad}}\n`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
83
src/main/index.ts
Normal file
83
src/main/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { app, BrowserWindow, nativeTheme, systemPreferences } from 'electron'
|
||||
import { createMainWindow, createReminderWindow, showMainWindow } from './windows'
|
||||
import { registerIpc } from './ipc'
|
||||
import { startScheduler, stopScheduler } from './scheduler'
|
||||
import { createTray } from './tray'
|
||||
import { flushNow, getState } from './store'
|
||||
import { wasStartedHidden } from './autostart'
|
||||
import { broadcastState } from './state-actions'
|
||||
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
|
||||
import { IPC } from '@shared/ipc'
|
||||
|
||||
const APP_ID = 'com.anril.exercise-reminder'
|
||||
|
||||
// Must be set BEFORE app.whenReady() for Windows toasts to show
|
||||
// the correct app name / icon in Action Center.
|
||||
app.setAppUserModelId(APP_ID)
|
||||
app.setName('Exercise Reminder')
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => showMainWindow())
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
createTray()
|
||||
|
||||
const hidden =
|
||||
wasStartedHidden() || getState().settings.startMinimized
|
||||
createMainWindow(!hidden)
|
||||
// Pre-create the reminder window so first-trigger is instant (no load lag).
|
||||
createReminderWindow()
|
||||
|
||||
startScheduler()
|
||||
startGamesRegistry().catch((err) =>
|
||||
console.error('games registry failed:', err)
|
||||
)
|
||||
|
||||
nativeTheme.on('updated', () => {
|
||||
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtThemeChanged, theme)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
systemPreferences.on('accent-color-changed' as never, () => {
|
||||
try {
|
||||
const color = '#' + systemPreferences.getAccentColor()
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtAccentChanged, color)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// older Electron / non-Windows
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep running in tray instead of quitting when all windows closed.
|
||||
if (!getState().settings.minimizeToTray) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopScheduler()
|
||||
void stopGamesRegistry()
|
||||
flushNow()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow(true)
|
||||
else showMainWindow()
|
||||
})
|
||||
|
||||
// Broadcast state once on ready so any prebuilt windows hydrate.
|
||||
app.whenReady().then(() => broadcastState())
|
||||
}
|
||||
204
src/main/ipc.ts
Normal file
204
src/main/ipc.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, shell } from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
||||
import {
|
||||
addChallenge,
|
||||
addExercise,
|
||||
deleteChallenge,
|
||||
deleteExercise,
|
||||
getState,
|
||||
markDone,
|
||||
setGameEnabled,
|
||||
skip,
|
||||
snooze,
|
||||
updateChallenge,
|
||||
updateExercise,
|
||||
updateSettings
|
||||
} from './store'
|
||||
import { broadcastState } from './state-actions'
|
||||
import { setAutostart, isAutostartEnabled } from './autostart'
|
||||
import { setPaused, forceCheck } from './scheduler'
|
||||
import { hideReminderWindow, getMainWindow } from './windows'
|
||||
import {
|
||||
broadcastGames,
|
||||
installGame,
|
||||
listGamesStatus,
|
||||
simulateMatchEnd,
|
||||
toggleGame,
|
||||
uninstallGame
|
||||
} from './games/registry'
|
||||
|
||||
export function registerIpc(): void {
|
||||
ipcMain.handle(IPC.getState, () => {
|
||||
const state = getState()
|
||||
state.settings.startWithWindows = isAutostartEnabled()
|
||||
return state
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.addExercise,
|
||||
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
|
||||
const ex = addExercise(input)
|
||||
broadcastState()
|
||||
return ex
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.updateExercise, (_e, id: string, patch: Partial<Exercise>) => {
|
||||
const ex = updateExercise(id, patch)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
|
||||
const ok = deleteExercise(id)
|
||||
broadcastState()
|
||||
return ok
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
|
||||
const patch: Partial<Exercise> = { enabled }
|
||||
if (enabled) {
|
||||
const ex = getState().exercises.find((e) => e.id === id)
|
||||
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
}
|
||||
const ex = updateExercise(id, patch)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.markDone, (_e, id: string) => {
|
||||
const ex = markDone(id)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
||||
const ex = snooze(id, minutes)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.skip, (_e, id: string) => {
|
||||
const ex = skip(id)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
|
||||
if (patch.startWithWindows !== undefined) {
|
||||
setAutostart(patch.startWithWindows)
|
||||
}
|
||||
const merged: Partial<Settings> = { ...patch }
|
||||
if (patch.startWithWindows !== undefined) {
|
||||
merged.startWithWindows = isAutostartEnabled()
|
||||
}
|
||||
const settings = updateSettings(merged)
|
||||
broadcastState()
|
||||
return settings
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.getAccentColor, () => {
|
||||
try {
|
||||
return '#' + systemPreferences.getAccentColor()
|
||||
} catch {
|
||||
return '#5B8DEF'
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.getOsTheme, () =>
|
||||
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.pauseAll, () => setPaused(true))
|
||||
ipcMain.handle(IPC.resumeAll, () => {
|
||||
setPaused(false)
|
||||
forceCheck()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.quit, () => app.quit())
|
||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||
|
||||
ipcMain.on(IPC.minimizeMain, (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.closeMain, () => {
|
||||
const main = getMainWindow()
|
||||
if (!main) return
|
||||
if (getState().settings.minimizeToTray) main.hide()
|
||||
else main.close()
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
|
||||
|
||||
// Games
|
||||
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
|
||||
|
||||
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
|
||||
const status = await installGame(id)
|
||||
setGameEnabled(id, true)
|
||||
await toggleGame(id, true)
|
||||
const all = await listGamesStatus()
|
||||
broadcastGames(all)
|
||||
broadcastState()
|
||||
return status
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
|
||||
const status = await uninstallGame(id)
|
||||
setGameEnabled(id, false)
|
||||
const all = await listGamesStatus()
|
||||
broadcastGames(all)
|
||||
broadcastState()
|
||||
return status
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
|
||||
setGameEnabled(id, enabled)
|
||||
await toggleGame(id, enabled)
|
||||
const all = await listGamesStatus()
|
||||
broadcastGames(all)
|
||||
broadcastState()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
|
||||
// Opens Steam's library; user manually adds launch options.
|
||||
shell.openExternal('steam://nav/games/details/570')
|
||||
})
|
||||
|
||||
// Challenges
|
||||
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
|
||||
const c = addChallenge(input)
|
||||
broadcastState()
|
||||
return c
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC.updateChallenge,
|
||||
(_e, id: string, patch: Partial<Challenge>) => {
|
||||
const c = updateChallenge(id, patch)
|
||||
broadcastState()
|
||||
return c
|
||||
}
|
||||
)
|
||||
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => {
|
||||
const ok = deleteChallenge(id)
|
||||
broadcastState()
|
||||
return ok
|
||||
})
|
||||
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
|
||||
const c = updateChallenge(id, { enabled })
|
||||
broadcastState()
|
||||
return c
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
||||
|
||||
// Dev helper: simulate a match end with given stats.
|
||||
ipcMain.handle(
|
||||
'dev:simulateMatchEnd',
|
||||
(_e, id: GameId, stats: Record<string, number>) => {
|
||||
simulateMatchEnd(id, stats)
|
||||
}
|
||||
)
|
||||
}
|
||||
65
src/main/notifications.ts
Normal file
65
src/main/notifications.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Notification, app } from 'electron'
|
||||
import type { Exercise, MatchSummary, NotificationMode } from '@shared/types'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import {
|
||||
createReminderWindow,
|
||||
getReminderWindow,
|
||||
showReminderWindow
|
||||
} from './windows'
|
||||
|
||||
export function fireReminder(exercise: Exercise, mode: NotificationMode): void {
|
||||
if (mode === 'toast' || mode === 'both') showToast(exercise)
|
||||
if (mode === 'modal' || mode === 'both') showModal(exercise)
|
||||
}
|
||||
|
||||
export function fireMatchSummary(summary: MatchSummary): void {
|
||||
if (Notification.isSupported()) {
|
||||
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
|
||||
const n = new Notification({
|
||||
title: `Матч ${summary.gameName} завершён`,
|
||||
body: `Челленджей: ${summary.results.length}, всего повторений: ${totalReps}`,
|
||||
silent: false
|
||||
})
|
||||
n.on('click', () => showReminderWindow())
|
||||
n.show()
|
||||
}
|
||||
const win = createReminderWindow()
|
||||
const send = (): void => {
|
||||
win.webContents.send(IPC.evtMatchEnd, summary)
|
||||
}
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once('did-finish-load', send)
|
||||
} else {
|
||||
send()
|
||||
}
|
||||
showReminderWindow()
|
||||
}
|
||||
|
||||
function showToast(exercise: Exercise): void {
|
||||
if (!Notification.isSupported()) return
|
||||
const n = new Notification({
|
||||
title: app.getName(),
|
||||
body: `${exercise.name} — ${exercise.reps}`,
|
||||
silent: false
|
||||
})
|
||||
n.on('click', () => showReminderWindow())
|
||||
n.show()
|
||||
}
|
||||
|
||||
function showModal(exercise: Exercise): void {
|
||||
const win = createReminderWindow()
|
||||
const send = (): void => {
|
||||
win.webContents.send(IPC.evtFire, exercise)
|
||||
}
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once('did-finish-load', send)
|
||||
} else {
|
||||
send()
|
||||
}
|
||||
showReminderWindow()
|
||||
}
|
||||
|
||||
export function notifyReminderClosed(): void {
|
||||
const win = getReminderWindow()
|
||||
if (win && !win.isDestroyed()) win.hide()
|
||||
}
|
||||
88
src/main/scheduler.ts
Normal file
88
src/main/scheduler.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { powerMonitor, BrowserWindow } from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type { Tick } from '@shared/types'
|
||||
import { getExercises, getSettings, updateExercise } from './store'
|
||||
import { fireReminder } from './notifications'
|
||||
|
||||
const TICK_MS = 1000
|
||||
const CHECK_MS = 5000
|
||||
let tickHandle: NodeJS.Timeout | null = null
|
||||
let lastCheckAt = 0
|
||||
let paused = false
|
||||
|
||||
function checkDueExercises(): void {
|
||||
if (paused) return
|
||||
const settings = getSettings()
|
||||
if (!settings.globalEnabled) return
|
||||
|
||||
const now = Date.now()
|
||||
const exercises = getExercises()
|
||||
for (const ex of exercises) {
|
||||
if (!ex.enabled) continue
|
||||
if (ex.nextFireAt <= now) {
|
||||
// Fire once, reschedule from now (drop missed intervals).
|
||||
const updated = updateExercise(ex.id, {
|
||||
nextFireAt: now + ex.intervalMinutes * 60_000
|
||||
})
|
||||
if (updated) fireReminder(updated, settings.notificationMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastTicks(): void {
|
||||
const now = Date.now()
|
||||
const ticks: Tick[] = getExercises().map((e) => ({
|
||||
exerciseId: e.id,
|
||||
msUntilFire: Math.max(0, e.nextFireAt - now),
|
||||
enabled: e.enabled
|
||||
}))
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtTick, ticks)
|
||||
}
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
broadcastTicks()
|
||||
const now = Date.now()
|
||||
if (now - lastCheckAt >= CHECK_MS) {
|
||||
lastCheckAt = now
|
||||
checkDueExercises()
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (tickHandle) return
|
||||
lastCheckAt = 0
|
||||
tickHandle = setInterval(tick, TICK_MS)
|
||||
// Run an immediate tick so renderer hydrates quickly.
|
||||
tick()
|
||||
|
||||
powerMonitor.on('resume', () => {
|
||||
lastCheckAt = 0
|
||||
tick()
|
||||
})
|
||||
powerMonitor.on('unlock-screen', () => {
|
||||
lastCheckAt = 0
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (tickHandle) {
|
||||
clearInterval(tickHandle)
|
||||
tickHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
export function setPaused(value: boolean): void {
|
||||
paused = value
|
||||
}
|
||||
|
||||
export function isPaused(): boolean {
|
||||
return paused
|
||||
}
|
||||
|
||||
export function forceCheck(): void {
|
||||
lastCheckAt = 0
|
||||
tick()
|
||||
}
|
||||
19
src/main/state-actions.ts
Normal file
19
src/main/state-actions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import { getExercises, getState, updateExercise } from './store'
|
||||
|
||||
export function broadcastState(): void {
|
||||
const state = getState()
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
|
||||
}
|
||||
}
|
||||
|
||||
export function snoozeAll(minutes: number): void {
|
||||
const now = Date.now()
|
||||
for (const ex of getExercises()) {
|
||||
if (!ex.enabled) continue
|
||||
updateExercise(ex.id, { nextFireAt: now + minutes * 60_000 })
|
||||
}
|
||||
broadcastState()
|
||||
}
|
||||
235
src/main/store.ts
Normal file
235
src/main/store.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
AppState,
|
||||
Challenge,
|
||||
DEFAULT_SETTINGS,
|
||||
Exercise,
|
||||
GameId,
|
||||
SAMPLE_EXERCISES,
|
||||
Settings
|
||||
} from '@shared/types'
|
||||
|
||||
let cache: AppState | null = null
|
||||
let storePath = ''
|
||||
let pendingWrite: NodeJS.Timeout | null = null
|
||||
|
||||
function getStorePath(): string {
|
||||
if (!storePath) {
|
||||
const dir = app.getPath('userData')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
storePath = join(dir, 'app-state.json')
|
||||
}
|
||||
return storePath
|
||||
}
|
||||
|
||||
function makeInitial(): AppState {
|
||||
const now = Date.now()
|
||||
return {
|
||||
exercises: SAMPLE_EXERCISES.map((e) => ({
|
||||
...e,
|
||||
id: randomUUID(),
|
||||
nextFireAt: now + e.intervalMinutes * 60_000
|
||||
})),
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
challenges: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
name: 'За смерти в Dota — приседания',
|
||||
gameId: 'dota2',
|
||||
stat: 'deaths',
|
||||
multiplier: 3,
|
||||
exerciseName: 'Приседания',
|
||||
icon: 'Activity',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
name: 'За убийства — отжимания',
|
||||
gameId: 'dota2',
|
||||
stat: 'kills',
|
||||
multiplier: 1,
|
||||
exerciseName: 'Отжимания',
|
||||
icon: 'Dumbbell',
|
||||
enabled: false
|
||||
}
|
||||
],
|
||||
gamesEnabled: {}
|
||||
}
|
||||
}
|
||||
|
||||
function load(): AppState {
|
||||
const p = getStorePath()
|
||||
if (!existsSync(p)) {
|
||||
const initial = makeInitial()
|
||||
writeFileSync(p, JSON.stringify(initial, null, 2), 'utf-8')
|
||||
return initial
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(p, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as Partial<AppState>
|
||||
return {
|
||||
exercises: parsed.exercises ?? [],
|
||||
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
||||
challenges: parsed.challenges ?? [],
|
||||
gamesEnabled: parsed.gamesEnabled ?? {}
|
||||
}
|
||||
} catch {
|
||||
return makeInitial()
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (!cache) return
|
||||
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function scheduleWrite(): void {
|
||||
if (pendingWrite) return
|
||||
pendingWrite = setTimeout(() => {
|
||||
pendingWrite = null
|
||||
flush()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
export function getState(): AppState {
|
||||
if (!cache) cache = load()
|
||||
return cache
|
||||
}
|
||||
|
||||
export function getSettings(): Settings {
|
||||
return getState().settings
|
||||
}
|
||||
|
||||
export function getExercises(): Exercise[] {
|
||||
return getState().exercises
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<Settings>): Settings {
|
||||
const state = getState()
|
||||
state.settings = { ...state.settings, ...patch }
|
||||
scheduleWrite()
|
||||
return state.settings
|
||||
}
|
||||
|
||||
export function addExercise(
|
||||
input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>
|
||||
): Exercise {
|
||||
const state = getState()
|
||||
const exercise: Exercise = {
|
||||
...input,
|
||||
id: randomUUID(),
|
||||
nextFireAt: Date.now() + input.intervalMinutes * 60_000
|
||||
}
|
||||
state.exercises.push(exercise)
|
||||
scheduleWrite()
|
||||
return exercise
|
||||
}
|
||||
|
||||
export function updateExercise(
|
||||
id: string,
|
||||
patch: Partial<Omit<Exercise, 'id'>>
|
||||
): Exercise | undefined {
|
||||
const state = getState()
|
||||
const idx = state.exercises.findIndex((e) => e.id === id)
|
||||
if (idx === -1) return undefined
|
||||
const prev = state.exercises[idx]
|
||||
const merged: Exercise = { ...prev, ...patch }
|
||||
// If interval changed, reschedule from now.
|
||||
if (patch.intervalMinutes !== undefined && patch.intervalMinutes !== prev.intervalMinutes) {
|
||||
merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000
|
||||
}
|
||||
state.exercises[idx] = merged
|
||||
scheduleWrite()
|
||||
return merged
|
||||
}
|
||||
|
||||
export function deleteExercise(id: string): boolean {
|
||||
const state = getState()
|
||||
const before = state.exercises.length
|
||||
state.exercises = state.exercises.filter((e) => e.id !== id)
|
||||
const changed = state.exercises.length !== before
|
||||
if (changed) scheduleWrite()
|
||||
return changed
|
||||
}
|
||||
|
||||
export function markDone(id: string): Exercise | undefined {
|
||||
const state = getState()
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.lastDoneAt = Date.now()
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
|
||||
export function snooze(id: string, minutes: number): Exercise | undefined {
|
||||
const state = getState()
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + minutes * 60_000
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
|
||||
export function skip(id: string): Exercise | undefined {
|
||||
const state = getState()
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
|
||||
export function flushNow(): void {
|
||||
if (pendingWrite) {
|
||||
clearTimeout(pendingWrite)
|
||||
pendingWrite = null
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
export function getChallenges(): Challenge[] {
|
||||
return getState().challenges
|
||||
}
|
||||
|
||||
export function addChallenge(input: Omit<Challenge, 'id'>): Challenge {
|
||||
const state = getState()
|
||||
const challenge: Challenge = { ...input, id: randomUUID() }
|
||||
state.challenges.push(challenge)
|
||||
scheduleWrite()
|
||||
return challenge
|
||||
}
|
||||
|
||||
export function updateChallenge(
|
||||
id: string,
|
||||
patch: Partial<Omit<Challenge, 'id'>>
|
||||
): Challenge | undefined {
|
||||
const state = getState()
|
||||
const idx = state.challenges.findIndex((c) => c.id === id)
|
||||
if (idx === -1) return undefined
|
||||
state.challenges[idx] = { ...state.challenges[idx], ...patch }
|
||||
scheduleWrite()
|
||||
return state.challenges[idx]
|
||||
}
|
||||
|
||||
export function deleteChallenge(id: string): boolean {
|
||||
const state = getState()
|
||||
const before = state.challenges.length
|
||||
state.challenges = state.challenges.filter((c) => c.id !== id)
|
||||
const changed = state.challenges.length !== before
|
||||
if (changed) scheduleWrite()
|
||||
return changed
|
||||
}
|
||||
|
||||
export function getGamesEnabled(): Partial<Record<GameId, boolean>> {
|
||||
return getState().gamesEnabled
|
||||
}
|
||||
|
||||
export function setGameEnabled(id: GameId, enabled: boolean): void {
|
||||
const state = getState()
|
||||
state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
|
||||
scheduleWrite()
|
||||
}
|
||||
68
src/main/tray.ts
Normal file
68
src/main/tray.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Tray, Menu, nativeImage, app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { showMainWindow } from './windows'
|
||||
import { isPaused, setPaused, forceCheck } from './scheduler'
|
||||
import { snoozeAll } from './state-actions'
|
||||
|
||||
let tray: Tray | null = null
|
||||
|
||||
function resolveTrayIcon(): Electron.NativeImage {
|
||||
// Try resources/, fallback to a transparent 16x16 if missing during dev.
|
||||
const candidates = [
|
||||
join(process.resourcesPath, 'tray.png'),
|
||||
join(__dirname, '../../resources/tray.png'),
|
||||
join(app.getAppPath(), 'resources/tray.png')
|
||||
]
|
||||
for (const p of candidates) {
|
||||
const img = nativeImage.createFromPath(p)
|
||||
if (!img.isEmpty()) return img
|
||||
}
|
||||
return nativeImage.createEmpty()
|
||||
}
|
||||
|
||||
export function createTray(): Tray {
|
||||
if (tray) return tray
|
||||
const icon = resolveTrayIcon()
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Exercise Reminder')
|
||||
refreshMenu()
|
||||
tray.on('click', () => showMainWindow())
|
||||
tray.on('double-click', () => showMainWindow())
|
||||
return tray
|
||||
}
|
||||
|
||||
export function refreshMenu(): void {
|
||||
if (!tray) return
|
||||
const paused = isPaused()
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: 'Открыть', click: () => showMainWindow() },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний',
|
||||
click: () => {
|
||||
setPaused(!paused)
|
||||
refreshMenu()
|
||||
if (!paused) forceCheck()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Отложить все на 15 мин',
|
||||
click: () => snoozeAll(15)
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Выход',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
tray.setContextMenu(menu)
|
||||
}
|
||||
|
||||
export function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
}
|
||||
157
src/main/windows.ts
Normal file
157
src/main/windows.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let reminderWindow: BrowserWindow | null = null
|
||||
|
||||
function preloadPath(): string {
|
||||
return join(__dirname, '../preload/index.js')
|
||||
}
|
||||
|
||||
function windowIcon(): Electron.NativeImage | undefined {
|
||||
const candidates = [
|
||||
join(process.resourcesPath, 'icon.png'),
|
||||
join(__dirname, '../../resources/icon.png'),
|
||||
join(app.getAppPath(), 'resources/icon.png')
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) {
|
||||
const img = nativeImage.createFromPath(p)
|
||||
if (!img.isEmpty()) return img
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function loadRoute(win: BrowserWindow, route: 'main' | 'reminder'): void {
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
if (devUrl) {
|
||||
win.loadURL(`${devUrl}?window=${route}`)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/index.html'), {
|
||||
search: `window=${route}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createMainWindow(showImmediately = true): BrowserWindow {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (showImmediately) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
const icon = windowIcon()
|
||||
const win = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
frame: false,
|
||||
backgroundColor: '#0f1117',
|
||||
titleBarStyle: 'hidden',
|
||||
autoHideMenuBar: true,
|
||||
...(icon ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
if (showImmediately) win.show()
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
loadRoute(win, 'main')
|
||||
mainWindow = win
|
||||
win.on('closed', () => {
|
||||
if (mainWindow === win) mainWindow = null
|
||||
})
|
||||
return win
|
||||
}
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
export function showMainWindow(): void {
|
||||
const win = createMainWindow(true)
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
export function createReminderWindow(): BrowserWindow {
|
||||
if (reminderWindow && !reminderWindow.isDestroyed()) return reminderWindow
|
||||
|
||||
const display = screen.getPrimaryDisplay()
|
||||
const width = 560
|
||||
const height = 520
|
||||
|
||||
const icon = windowIcon()
|
||||
const win = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
x: Math.round(display.workArea.x + (display.workArea.width - width) / 2),
|
||||
y: Math.round(display.workArea.y + (display.workArea.height - height) / 2),
|
||||
show: false,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
fullscreenable: false,
|
||||
skipTaskbar: true,
|
||||
movable: true,
|
||||
alwaysOnTop: true,
|
||||
backgroundColor: '#0f1117',
|
||||
...(icon ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
win.setAlwaysOnTop(true, 'screen-saver')
|
||||
loadRoute(win, 'reminder')
|
||||
|
||||
win.on('closed', () => {
|
||||
if (reminderWindow === win) reminderWindow = null
|
||||
})
|
||||
|
||||
reminderWindow = win
|
||||
return win
|
||||
}
|
||||
|
||||
export function showReminderWindow(): void {
|
||||
const win = createReminderWindow()
|
||||
// Recenter in case display config changed.
|
||||
const display = screen.getPrimaryDisplay()
|
||||
const [w, h] = win.getSize()
|
||||
win.setBounds({
|
||||
x: Math.round(display.workArea.x + (display.workArea.width - w) / 2),
|
||||
y: Math.round(display.workArea.y + (display.workArea.height - h) / 2),
|
||||
width: w,
|
||||
height: h
|
||||
})
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
export function hideReminderWindow(): void {
|
||||
if (reminderWindow && !reminderWindow.isDestroyed()) reminderWindow.hide()
|
||||
}
|
||||
|
||||
export function getReminderWindow(): BrowserWindow | null {
|
||||
return reminderWindow
|
||||
}
|
||||
7
src/preload/index.d.ts
vendored
Normal file
7
src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Api } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: Api
|
||||
}
|
||||
}
|
||||
92
src/preload/index.ts
Normal file
92
src/preload/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type {
|
||||
AppState,
|
||||
Challenge,
|
||||
Exercise,
|
||||
GameId,
|
||||
GameStatus,
|
||||
MatchSummary,
|
||||
Settings,
|
||||
Tick
|
||||
} from '@shared/types'
|
||||
|
||||
type Unsub = () => void
|
||||
type Handler<T> = (payload: T) => void
|
||||
|
||||
function on<T>(channel: string, handler: Handler<T>): Unsub {
|
||||
const listener = (_e: Electron.IpcRendererEvent, payload: T): void => handler(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
|
||||
const api = {
|
||||
getState: (): Promise<AppState> => ipcRenderer.invoke(IPC.getState),
|
||||
|
||||
addExercise: (
|
||||
input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>
|
||||
): Promise<Exercise> => ipcRenderer.invoke(IPC.addExercise, input),
|
||||
updateExercise: (id: string, patch: Partial<Exercise>): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.updateExercise, id, patch),
|
||||
deleteExercise: (id: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC.deleteExercise, id),
|
||||
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
|
||||
markDone: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.markDone, id),
|
||||
snooze: (id: string, minutes: number): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.snooze, id, minutes),
|
||||
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
|
||||
|
||||
updateSettings: (patch: Partial<Settings>): Promise<Settings> =>
|
||||
ipcRenderer.invoke(IPC.updateSettings, patch),
|
||||
|
||||
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
|
||||
getOsTheme: (): Promise<'light' | 'dark'> => ipcRenderer.invoke(IPC.getOsTheme),
|
||||
|
||||
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
|
||||
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC.quit),
|
||||
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
|
||||
|
||||
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
|
||||
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
|
||||
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
|
||||
|
||||
// Games
|
||||
listGames: (): Promise<GameStatus[]> => ipcRenderer.invoke(IPC.gamesList),
|
||||
installGame: (id: GameId): Promise<GameStatus> =>
|
||||
ipcRenderer.invoke(IPC.gameInstall, id),
|
||||
uninstallGame: (id: GameId): Promise<GameStatus> =>
|
||||
ipcRenderer.invoke(IPC.gameUninstall, id),
|
||||
toggleGame: (id: GameId, enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.gameToggle, id, enabled),
|
||||
openGameLaunchOptions: (id: GameId): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.gameOpenLaunchOptions, id),
|
||||
|
||||
// Challenges
|
||||
addChallenge: (input: Omit<Challenge, 'id'>): Promise<Challenge> =>
|
||||
ipcRenderer.invoke(IPC.addChallenge, input),
|
||||
updateChallenge: (id: string, patch: Partial<Challenge>): Promise<Challenge> =>
|
||||
ipcRenderer.invoke(IPC.updateChallenge, id, patch),
|
||||
deleteChallenge: (id: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC.deleteChallenge, id),
|
||||
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
|
||||
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
|
||||
|
||||
closeMatchSummary: (): Promise<void> => ipcRenderer.invoke(IPC.closeMatchSummary),
|
||||
|
||||
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> =>
|
||||
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats),
|
||||
|
||||
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
|
||||
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
||||
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
||||
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
|
||||
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => on(IPC.evtThemeChanged, h),
|
||||
onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, h),
|
||||
onGamesChanged: (h: Handler<GameStatus[]>): Unsub => on(IPC.evtGamesChanged, h)
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
|
||||
export type Api = typeof api
|
||||
16
src/renderer/index.html
Normal file
16
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'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; script-src 'self'" />
|
||||
<title>Exercise Reminder</title>
|
||||
<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&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
src/renderer/src/App.tsx
Normal file
43
src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Exercises from './pages/Exercises'
|
||||
import GamesPage from './pages/Games'
|
||||
import ChallengesPage from './pages/Challenges'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import { subscribeToBackend, useAppStore } from './store/appStore'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const hydrated = useAppStore((s) => s.hydrated)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeToBackend()
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<div className="h-screen w-screen flex flex-col bg-bg">
|
||||
<Titlebar title="Exercise Reminder" />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{hydrated ? (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/exercises" element={<Exercises />} />
|
||||
<Route path="/games" element={<GamesPage />} />
|
||||
<Route path="/challenges" element={<ChallengesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<div className="p-8 text-muted">Загрузка…</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
326
src/renderer/src/ReminderApp.tsx
Normal file
326
src/renderer/src/ReminderApp.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Clock, X, Trophy, Skull, Gamepad2 } from 'lucide-react'
|
||||
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
|
||||
import { Icon } from './lib/icon'
|
||||
import { formatInterval } from './lib/format'
|
||||
|
||||
type Mode =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'exercise'; exercise: Exercise }
|
||||
| { kind: 'match'; summary: MatchSummary; done: Set<string> }
|
||||
|
||||
export default function ReminderApp(): JSX.Element {
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const settingsRef = useRef<Settings | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getState().then((s) => setSettings(s.settings))
|
||||
const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
|
||||
const u1 = window.api.onFire((ex) => {
|
||||
setMode({ kind: 'exercise', exercise: ex })
|
||||
if (settingsRef.current?.soundEnabled) playBeep()
|
||||
})
|
||||
const u2 = window.api.onMatchEnd((summary) => {
|
||||
setMode({ kind: 'match', summary, done: new Set() })
|
||||
if (settingsRef.current?.soundEnabled) playBeep()
|
||||
})
|
||||
return () => {
|
||||
u0()
|
||||
u1()
|
||||
u2()
|
||||
}
|
||||
}, [])
|
||||
|
||||
function close(): void {
|
||||
setMode({ kind: 'idle' })
|
||||
window.api.reminderClose()
|
||||
}
|
||||
|
||||
if (mode.kind === 'idle') return <div className="reminder-shell" />
|
||||
if (mode.kind === 'exercise') {
|
||||
return (
|
||||
<ExerciseReminder
|
||||
exercise={mode.exercise}
|
||||
snoozeMinutes={settings?.snoozeMinutes ?? 5}
|
||||
onClose={close}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MatchSummaryView
|
||||
summary={mode.summary}
|
||||
done={mode.done}
|
||||
onMarkDone={(id) =>
|
||||
setMode({
|
||||
kind: 'match',
|
||||
summary: mode.summary,
|
||||
done: new Set([...mode.done, id])
|
||||
})
|
||||
}
|
||||
onClose={close}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseReminder({
|
||||
exercise,
|
||||
snoozeMinutes,
|
||||
onClose
|
||||
}: {
|
||||
exercise: Exercise
|
||||
snoozeMinutes: number
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
async function done(): Promise<void> {
|
||||
await window.api.markDone(exercise.id)
|
||||
onClose()
|
||||
}
|
||||
async function snooze(): Promise<void> {
|
||||
await window.api.snooze(exercise.id, snoozeMinutes)
|
||||
onClose()
|
||||
}
|
||||
async function skip(): Promise<void> {
|
||||
await window.api.skip(exercise.id)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
<div className="titlebar-drag h-8 px-3 flex items-center justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
|
||||
className="relative mb-5"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-accent/30 animate-pulse-ring" />
|
||||
<div className="relative w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-glow">
|
||||
<Icon name={exercise.icon} size={44} />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted">Время размяться</div>
|
||||
<h1 className="text-3xl font-bold mt-2 mb-1">{exercise.name}</h1>
|
||||
<div className="text-5xl font-extrabold text-accent tabular-nums mt-1">
|
||||
{exercise.reps}
|
||||
<span className="text-base font-medium text-muted ml-2">раз</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-2">
|
||||
Следующее напоминание через {formatInterval(exercise.intervalMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={skip}
|
||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-muted hover:text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<X size={14} /> Пропустить
|
||||
</button>
|
||||
<button
|
||||
onClick={snooze}
|
||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Clock size={14} /> Отложить {snoozeMinutes}м
|
||||
</button>
|
||||
<button
|
||||
onClick={done}
|
||||
className="h-12 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center justify-center gap-1.5 shadow-glow"
|
||||
>
|
||||
<Check size={16} /> Сделал
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchSummaryView({
|
||||
summary,
|
||||
done,
|
||||
onMarkDone,
|
||||
onClose
|
||||
}: {
|
||||
summary: MatchSummary
|
||||
done: Set<string>
|
||||
onMarkDone: (id: string) => void
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const allDone = summary.results.every((r) => done.has(r.challengeId))
|
||||
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
|
||||
const remainingReps = summary.results
|
||||
.filter((r) => !done.has(r.challengeId))
|
||||
.reduce((s, r) => s + r.reps, 0)
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted inline-flex items-center gap-1.5 px-2">
|
||||
<Gamepad2 size={12} /> {summary.gameName}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-2 pb-4 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
|
||||
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/15 text-accent mb-3"
|
||||
>
|
||||
{summary.won === true ? (
|
||||
<Trophy size={28} />
|
||||
) : summary.won === false ? (
|
||||
<Skull size={28} />
|
||||
) : (
|
||||
<Gamepad2 size={28} />
|
||||
)}
|
||||
</motion.div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{summary.won === true
|
||||
? 'Победа! Время заработанных упражнений'
|
||||
: summary.won === false
|
||||
? 'Поражение. Но тело — нет'
|
||||
: 'Матч завершён'}
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
{Math.floor(summary.durationMs / 60_000)} мин · {summary.results.length}{' '}
|
||||
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||
{allDone ? (
|
||||
<span className="text-emerald-500 font-medium">всё выполнено</span>
|
||||
) : (
|
||||
<span className="text-accent font-semibold">{remainingReps} ещё</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-2">
|
||||
{summary.results.map((r) => (
|
||||
<ChallengeRow
|
||||
key={r.challengeId}
|
||||
result={r}
|
||||
done={done.has(r.challengeId)}
|
||||
onMarkDone={() => onMarkDone(r.challengeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-3 flex items-center gap-2">
|
||||
<div className="flex-1 text-xs text-muted">
|
||||
Всего: <span className="text-text font-semibold">{totalReps}</span>{' '}
|
||||
повторений
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-11 px-5 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center gap-1.5 shadow-glow"
|
||||
>
|
||||
{allDone ? (
|
||||
<>
|
||||
<Check size={16} /> Готово
|
||||
</>
|
||||
) : (
|
||||
'Позже'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChallengeRow({
|
||||
result,
|
||||
done,
|
||||
onMarkDone
|
||||
}: {
|
||||
result: ChallengeResult
|
||||
done: boolean
|
||||
onMarkDone: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
|
||||
done
|
||||
? 'border-emerald-500/40 bg-emerald-500/10'
|
||||
: 'border-border bg-surface-elevated'
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
|
||||
done ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={result.icon} size={22} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={['font-medium truncate', done ? 'line-through opacity-60' : ''].join(' ')}>
|
||||
{result.exerciseName}
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{result.statValue} {result.statLabel} → {result.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className={['text-2xl font-bold tabular-nums', done ? 'text-emerald-500' : 'text-accent'].join(' ')}>
|
||||
{result.reps}
|
||||
</div>
|
||||
<button
|
||||
onClick={onMarkDone}
|
||||
disabled={done}
|
||||
className={[
|
||||
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
|
||||
done
|
||||
? 'bg-emerald-500 text-white cursor-default'
|
||||
: 'bg-accent text-white hover:brightness-110'
|
||||
].join(' ')}
|
||||
aria-label="Готово"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function playBeep(): void {
|
||||
try {
|
||||
const Ctx =
|
||||
(window.AudioContext as typeof AudioContext | undefined) ||
|
||||
((window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext as typeof AudioContext)
|
||||
const ctx = new Ctx()
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.frequency.value = 660
|
||||
osc.type = 'sine'
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime)
|
||||
gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.02)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45)
|
||||
osc.connect(gain).connect(ctx.destination)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.5)
|
||||
osc.onended = () => ctx.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
116
src/renderer/src/components/ExerciseCard.tsx
Normal file
116
src/renderer/src/components/ExerciseCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Pencil, Trash2 } from 'lucide-react'
|
||||
import type { Exercise, Tick } from '@shared/types'
|
||||
import { Icon } from '../lib/icon'
|
||||
import { formatCountdown, formatInterval } from '../lib/format'
|
||||
import { Switch } from './ui/Switch'
|
||||
|
||||
type Props = {
|
||||
exercise: Exercise
|
||||
tick?: Tick
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
onMarkDone: () => void
|
||||
}
|
||||
|
||||
export function ExerciseCard({
|
||||
exercise,
|
||||
tick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onMarkDone
|
||||
}: Props): JSX.Element {
|
||||
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
|
||||
const progressPct = (() => {
|
||||
const total = exercise.intervalMinutes * 60_000
|
||||
const remaining = Math.max(0, Math.min(total, ms))
|
||||
const elapsed = total - remaining
|
||||
return Math.max(0, Math.min(100, (elapsed / total) * 100))
|
||||
})()
|
||||
const isDue = ms <= 0 && exercise.enabled
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={[
|
||||
'group rounded-2xl border bg-surface p-5 flex flex-col gap-4 transition-shadow',
|
||||
isDue ? 'border-accent shadow-glow' : 'border-border hover:shadow-soft'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={[
|
||||
'w-12 h-12 rounded-xl grid place-items-center',
|
||||
exercise.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={exercise.icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold leading-tight">{exercise.name}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={exercise.enabled}
|
||||
onChange={onToggle}
|
||||
aria-label="Включить/выключить"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs uppercase tracking-wider text-muted">
|
||||
Следующее
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'text-sm font-mono font-semibold tabular-nums',
|
||||
isDue ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-surface-elevated overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-accent"
|
||||
animate={{ width: `${exercise.enabled ? progressPct : 0}%` }}
|
||||
transition={{ duration: 0.5, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onMarkDone}
|
||||
className="flex-1 h-9 rounded-lg bg-accent/10 hover:bg-accent/20 text-accent text-xs font-medium inline-flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Check size={14} /> Сделал сейчас
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
|
||||
aria-label="Редактировать"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
161
src/renderer/src/components/ExerciseEditor.tsx
Normal file
161
src/renderer/src/components/ExerciseEditor.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { ICON_CHOICES, Icon } from '../lib/icon'
|
||||
|
||||
type Draft = {
|
||||
name: string
|
||||
reps: number
|
||||
icon: string
|
||||
intervalMinutes: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: '',
|
||||
reps: 10,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
exercise?: Exercise | null
|
||||
onClose: () => void
|
||||
onSave: (draft: Draft) => void
|
||||
}
|
||||
|
||||
export function ExerciseEditor({ open, exercise, onClose, onSave }: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY)
|
||||
|
||||
useEffect(() => {
|
||||
if (exercise) {
|
||||
setDraft({
|
||||
name: exercise.name,
|
||||
reps: exercise.reps,
|
||||
icon: exercise.icon,
|
||||
intervalMinutes: exercise.intervalMinutes,
|
||||
enabled: exercise.enabled
|
||||
})
|
||||
} else {
|
||||
setDraft(EMPTY)
|
||||
}
|
||||
}, [exercise, open])
|
||||
|
||||
const canSave = draft.name.trim().length > 0 && draft.intervalMinutes >= 1
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={exercise ? 'Редактировать упражнение' : 'Новое упражнение'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Field label="Название">
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="Например, приседания"
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Повторений">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.reps}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, reps: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Интервал (мин)">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.intervalMinutes}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
intervalMinutes: Math.max(1, Number(e.target.value) || 1)
|
||||
})
|
||||
}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Иконка">
|
||||
<div className="grid grid-cols-9 gap-2 max-h-48 overflow-y-auto pr-1">
|
||||
{ICON_CHOICES.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, icon: name })}
|
||||
className={[
|
||||
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
|
||||
draft.icon === name
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-border bg-surface-elevated text-muted hover:text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={name} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<style>{`
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgb(var(--border));
|
||||
background: rgb(var(--surface-elevated));
|
||||
color: rgb(var(--text));
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: rgb(var(--accent));
|
||||
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
57
src/renderer/src/components/Sidebar.tsx
Normal file
57
src/renderer/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Gamepad2,
|
||||
Target,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react'
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboard, end: true },
|
||||
{ to: '/exercises', label: 'Упражнения', icon: ListChecks },
|
||||
{ to: '/games', label: 'Игры', icon: Gamepad2 },
|
||||
{ to: '/challenges', label: 'Челленджи', icon: Target },
|
||||
{ to: '/settings', label: 'Настройки', icon: SettingsIcon }
|
||||
]
|
||||
|
||||
export function Sidebar(): JSX.Element {
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-border/60 bg-surface/40 flex flex-col">
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent grid place-items-center text-white font-bold shadow-glow">
|
||||
R
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm leading-tight">Reminder</div>
|
||||
<div className="text-xs text-muted">Будь в движении</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-3 flex flex-col gap-1">
|
||||
{links.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted hover:text-text hover:bg-surface-elevated'
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto p-4 text-xs text-muted">
|
||||
<div className="opacity-60">v0.1 · Windows 11</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
35
src/renderer/src/components/Titlebar.tsx
Normal file
35
src/renderer/src/components/Titlebar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Minus, X, Square } from 'lucide-react'
|
||||
|
||||
export function Titlebar({ title }: { title: string }): JSX.Element {
|
||||
return (
|
||||
<div className="titlebar-drag h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/60 backdrop-blur">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted">
|
||||
<span className="w-2 h-2 rounded-full bg-accent shadow-glow" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="titlebar-nodrag flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => window.api.minimizeMain()}
|
||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
|
||||
aria-label="Свернуть"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.api.hideMain()}
|
||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-surface-elevated transition-colors text-muted hover:text-text"
|
||||
aria-label="Скрыть в трей"
|
||||
>
|
||||
<Square size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.api.closeMain()}
|
||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white transition-colors text-muted"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/renderer/src/components/ui/Button.tsx
Normal file
42
src/renderer/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-accent text-white hover:brightness-110 active:brightness-95 shadow-soft',
|
||||
secondary:
|
||||
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border',
|
||||
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 shadow-soft'
|
||||
}
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base'
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
{ variant = 'primary', size = 'md', className = '', ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={[
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
].join(' ')}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
81
src/renderer/src/components/ui/Modal.tsx
Normal file
81
src/renderer/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl'
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md'
|
||||
}: Props): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-black/40 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={[
|
||||
'w-full mx-4 bg-surface rounded-2xl border border-border shadow-soft flex flex-col',
|
||||
sizeClass[size]
|
||||
].join(' ')}
|
||||
initial={{ scale: 0.96, y: 12, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.96, y: 8, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
|
||||
<h3 className="font-semibold text-base">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 grid place-items-center rounded-md hover:bg-surface-elevated text-muted hover:text-text"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[70vh]">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border/60 flex justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
32
src/renderer/src/components/ui/Switch.tsx
Normal file
32
src/renderer/src/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
type Props = {
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, disabled, ...rest }: Props): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={rest['aria-label']}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors duration-200 outline-none',
|
||||
checked ? 'bg-accent' : 'bg-border',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform duration-200',
|
||||
checked ? 'translate-x-[22px]' : 'translate-x-0.5',
|
||||
'mt-0.5'
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
17
src/renderer/src/lib/format.ts
Normal file
17
src/renderer/src/lib/format.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function formatCountdown(ms: number): string {
|
||||
if (ms <= 0) return 'сейчас'
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const h = Math.floor(totalSec / 3600)
|
||||
const m = Math.floor((totalSec % 3600) / 60)
|
||||
const s = totalSec % 60
|
||||
if (h > 0) return `${h}ч ${String(m).padStart(2, '0')}м`
|
||||
if (m > 0) return `${m}м ${String(s).padStart(2, '0')}с`
|
||||
return `${s}с`
|
||||
}
|
||||
|
||||
export function formatInterval(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h} ч` : `${h} ч ${m} мин`
|
||||
}
|
||||
36
src/renderer/src/lib/icon.tsx
Normal file
36
src/renderer/src/lib/icon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as Lucide from 'lucide-react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
export const ICON_CHOICES = [
|
||||
'Activity',
|
||||
'Dumbbell',
|
||||
'StretchHorizontal',
|
||||
'PersonStanding',
|
||||
'Heart',
|
||||
'Footprints',
|
||||
'Hand',
|
||||
'Eye',
|
||||
'Brain',
|
||||
'Bike',
|
||||
'Waves',
|
||||
'Wind',
|
||||
'Sun',
|
||||
'Coffee',
|
||||
'Apple',
|
||||
'GlassWater',
|
||||
'BookOpen',
|
||||
'Sparkles'
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof ICON_CHOICES)[number]
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
...props
|
||||
}: { name: string } & LucideProps): JSX.Element {
|
||||
const Cmp = (Lucide as unknown as Record<string, React.ComponentType<LucideProps>>)[
|
||||
name
|
||||
]
|
||||
if (!Cmp) return <Lucide.Activity {...props} />
|
||||
return <Cmp {...props} />
|
||||
}
|
||||
15
src/renderer/src/main.tsx
Normal file
15
src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './styles/globals.css'
|
||||
import App from './App'
|
||||
import ReminderApp from './ReminderApp'
|
||||
import { ThemeProvider } from './providers/ThemeProvider'
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const which = params.get('window') ?? 'main'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>{which === 'reminder' ? <ReminderApp /> : <App />}</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
312
src/renderer/src/pages/Challenges.tsx
Normal file
312
src/renderer/src/pages/Challenges.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Pencil, Trash2, Gamepad2 } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Modal } from '../components/ui/Modal'
|
||||
import { ICON_CHOICES, Icon } from '../lib/icon'
|
||||
import { GAME_STATS, STAT_LABELS } from '@shared/types'
|
||||
import type { Challenge, GameId, GameStat, GameStatus } from '@shared/types'
|
||||
|
||||
const GAME_NAMES: Record<GameId, string> = {
|
||||
dota2: 'Dota 2'
|
||||
}
|
||||
|
||||
type Draft = Omit<Challenge, 'id'>
|
||||
|
||||
const EMPTY_DRAFT: Draft = {
|
||||
name: '',
|
||||
gameId: 'dota2',
|
||||
stat: 'deaths',
|
||||
multiplier: 3,
|
||||
exerciseName: 'Приседания',
|
||||
icon: 'Activity',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
export default function ChallengesPage(): JSX.Element {
|
||||
const challenges = useAppStore((s) => s.state?.challenges ?? [])
|
||||
const [games, setGames] = useState<GameStatus[]>([])
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Challenge | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.listGames().then(setGames)
|
||||
return window.api.onGamesChanged(setGames)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Челленджи</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
После матча — повторений = статистика × коэффициент
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={16} /> Новый
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
|
||||
{challenges.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={[
|
||||
'flex items-center gap-4 px-5 py-4',
|
||||
i < challenges.length - 1 ? 'border-b border-border/60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
||||
c.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={c.icon} size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{c.name}</div>
|
||||
<div className="text-xs text-muted mt-0.5 inline-flex items-center gap-1.5">
|
||||
<Gamepad2 size={12} /> {GAME_NAMES[c.gameId]} ·{' '}
|
||||
{STAT_LABELS[c.stat]} × {c.multiplier} = {c.exerciseName}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={c.enabled}
|
||||
onChange={(v) => window.api.toggleChallenge(c.id, v)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(c)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
|
||||
aria-label="Редактировать"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.api.deleteChallenge(c.id)}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{challenges.length === 0 && (
|
||||
<div className="px-5 py-12 text-center text-muted">
|
||||
Челленджей пока нет. Добавь первый.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{games.length > 0 && !games.some((g) => g.enabled) && (
|
||||
<div className="mt-6 rounded-xl bg-amber-500/10 border border-amber-500/30 p-4 text-sm">
|
||||
Челленджи запускаются после матча. Сначала подключи игру в разделе{' '}
|
||||
<strong>Игры</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChallengeEditor
|
||||
open={editorOpen}
|
||||
challenge={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={async (draft) => {
|
||||
if (editing) await window.api.updateChallenge(editing.id, draft)
|
||||
else await window.api.addChallenge(draft)
|
||||
setEditorOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChallengeEditor({
|
||||
open,
|
||||
challenge,
|
||||
onClose,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean
|
||||
challenge: Challenge | null
|
||||
onClose: () => void
|
||||
onSave: (draft: Draft) => void
|
||||
}): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
|
||||
|
||||
useEffect(() => {
|
||||
if (challenge) {
|
||||
setDraft({
|
||||
name: challenge.name,
|
||||
gameId: challenge.gameId,
|
||||
stat: challenge.stat,
|
||||
multiplier: challenge.multiplier,
|
||||
exerciseName: challenge.exerciseName,
|
||||
icon: challenge.icon,
|
||||
enabled: challenge.enabled
|
||||
})
|
||||
} else {
|
||||
setDraft(EMPTY_DRAFT)
|
||||
}
|
||||
}, [challenge, open])
|
||||
|
||||
const canSave =
|
||||
draft.name.trim().length > 0 &&
|
||||
draft.exerciseName.trim().length > 0 &&
|
||||
draft.multiplier > 0
|
||||
|
||||
const previewReps = Math.round(5 * draft.multiplier)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={challenge ? 'Редактировать челлендж' : 'Новый челлендж'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Field label="Название">
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="Например, за смерти — приседания"
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Игра">
|
||||
<select
|
||||
value={draft.gameId}
|
||||
onChange={(e) => setDraft({ ...draft, gameId: e.target.value as GameId })}
|
||||
className="input"
|
||||
>
|
||||
{(Object.keys(GAME_NAMES) as GameId[]).map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GAME_NAMES[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Стат">
|
||||
<select
|
||||
value={draft.stat}
|
||||
onChange={(e) => setDraft({ ...draft, stat: e.target.value as GameStat })}
|
||||
className="input"
|
||||
>
|
||||
{GAME_STATS[draft.gameId].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STAT_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Коэффициент">
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={draft.multiplier}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, multiplier: Math.max(0.5, Number(e.target.value) || 1) })
|
||||
}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Упражнение">
|
||||
<input
|
||||
value={draft.exerciseName}
|
||||
onChange={(e) => setDraft({ ...draft, exerciseName: e.target.value })}
|
||||
placeholder="Например, приседания"
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Иконка">
|
||||
<div className="grid grid-cols-9 gap-2 max-h-40 overflow-y-auto pr-1">
|
||||
{ICON_CHOICES.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, icon: name })}
|
||||
className={[
|
||||
'h-10 w-10 grid place-items-center rounded-lg border transition-colors',
|
||||
draft.icon === name
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-border bg-surface-elevated text-muted hover:text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={name} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl bg-surface-elevated p-4 text-sm text-muted">
|
||||
Пример: 5 {STAT_LABELS[draft.stat]} × {draft.multiplier} ={' '}
|
||||
<span className="text-accent font-semibold">{previewReps}</span>{' '}
|
||||
{draft.exerciseName.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgb(var(--border));
|
||||
background: rgb(var(--surface-elevated));
|
||||
color: rgb(var(--text));
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: rgb(var(--accent));
|
||||
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.2);
|
||||
}
|
||||
select.input {
|
||||
padding-right: 32px;
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-muted mb-1.5 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
144
src/renderer/src/pages/Dashboard.tsx
Normal file
144
src/renderer/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Pause, Play, Clock } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { ExerciseCard } from '../components/ExerciseCard'
|
||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
const ticks = useAppStore((s) => s.ticks)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||
|
||||
const exercises = state?.exercises ?? []
|
||||
const settings = state?.settings
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const enabled = exercises.filter((e) => e.enabled)
|
||||
const next = enabled
|
||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||
.sort((a, b) => a.ms - b.ms)[0]
|
||||
return {
|
||||
total: exercises.length,
|
||||
active: enabled.length,
|
||||
nextMs: next?.ms ?? Infinity
|
||||
}
|
||||
}, [exercises, ticks])
|
||||
|
||||
function openCreate(): void {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(ex: Exercise): void {
|
||||
setEditing(ex)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave(draft: {
|
||||
name: string
|
||||
reps: number
|
||||
icon: string
|
||||
intervalMinutes: number
|
||||
enabled: boolean
|
||||
}): Promise<void> {
|
||||
if (editing) {
|
||||
await window.api.updateExercise(editing.id, draft)
|
||||
} else {
|
||||
await window.api.addExercise(draft)
|
||||
}
|
||||
setEditorOpen(false)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string): Promise<void> {
|
||||
await window.api.deleteExercise(id)
|
||||
}
|
||||
|
||||
async function togglePause(): Promise<void> {
|
||||
if (!settings) return
|
||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{stats.active} активных из {stats.total} упражнений
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={togglePause}>
|
||||
{settings?.globalEnabled ? (
|
||||
<>
|
||||
<Pause size={16} /> Пауза
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} /> Возобновить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} /> Новое
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-2xl border border-border bg-surface px-5 py-4 flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent/15 text-accent grid place-items-center">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted uppercase tracking-wider">
|
||||
Ближайшее напоминание
|
||||
</div>
|
||||
<div className="text-lg font-semibold mt-0.5">
|
||||
{stats.nextMs === Infinity
|
||||
? 'Нет активных упражнений'
|
||||
: `через ${formatCountdown(stats.nextMs)}`}
|
||||
</div>
|
||||
</div>
|
||||
{!settings?.globalEnabled && (
|
||||
<div className="px-3 py-1.5 rounded-full bg-amber-500/15 text-amber-500 text-xs font-medium">
|
||||
Напоминания на паузе
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{exercises.map((ex) => (
|
||||
<ExerciseCard
|
||||
key={ex.id}
|
||||
exercise={ex}
|
||||
tick={ticks[ex.id]}
|
||||
onEdit={() => openEdit(ex)}
|
||||
onDelete={() => handleDelete(ex.id)}
|
||||
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
|
||||
onMarkDone={() => window.api.markDone(ex.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{exercises.length === 0 && (
|
||||
<div className="mt-10 text-center text-muted">
|
||||
<p>Нет упражнений. Добавьте первое.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExerciseEditor
|
||||
open={editorOpen}
|
||||
exercise={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
src/renderer/src/pages/Exercises.tsx
Normal file
98
src/renderer/src/pages/Exercises.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Icon } from '../lib/icon'
|
||||
import { formatInterval } from '../lib/format'
|
||||
import type { Exercise } from '@shared/types'
|
||||
|
||||
export default function Exercises(): JSX.Element {
|
||||
const exercises = useAppStore((s) => s.state?.exercises ?? [])
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Упражнения</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Управляйте всеми упражнениями в одном месте
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={16} /> Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
|
||||
{exercises.map((ex, i) => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={[
|
||||
'flex items-center gap-4 px-5 py-4',
|
||||
i < exercises.length - 1 ? 'border-b border-border/60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-11 h-11 rounded-xl grid place-items-center shrink-0',
|
||||
ex.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={ex.icon} size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{ex.name}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{ex.reps} раз · каждые {formatInterval(ex.intervalMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ex.enabled}
|
||||
onChange={(v) => window.api.toggleExercise(ex.id, v)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(ex)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
|
||||
aria-label="Редактировать"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.api.deleteExercise(ex.id)}
|
||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{exercises.length === 0 && (
|
||||
<div className="px-5 py-12 text-center text-muted">Список пуст</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExerciseEditor
|
||||
open={editorOpen}
|
||||
exercise={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={async (draft) => {
|
||||
if (editing) await window.api.updateExercise(editing.id, draft)
|
||||
else await window.api.addExercise(draft)
|
||||
setEditorOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
src/renderer/src/pages/Games.tsx
Normal file
211
src/renderer/src/pages/Games.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
Hourglass
|
||||
} from 'lucide-react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import type { GameId, GameStatus } from '@shared/types'
|
||||
|
||||
export default function GamesPage(): JSX.Element {
|
||||
const [games, setGames] = useState<GameStatus[]>([])
|
||||
const [busy, setBusy] = useState<GameId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
const unsub = window.api.onGamesChanged(setGames)
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setGames(await window.api.listGames())
|
||||
}
|
||||
|
||||
async function install(id: GameId): Promise<void> {
|
||||
setBusy(id)
|
||||
try {
|
||||
await window.api.installGame(id)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall(id: GameId): Promise<void> {
|
||||
setBusy(id)
|
||||
try {
|
||||
await window.api.uninstallGame(id)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(id: GameId, enabled: boolean): Promise<void> {
|
||||
setBusy(id)
|
||||
try {
|
||||
await window.api.toggleGame(id, enabled)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full max-w-3xl">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Игры</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Подключите свою игру — приложение будет триггерить челленджи после матча
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={refresh}>
|
||||
<RefreshCw size={16} /> Обновить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{games.map((g) => (
|
||||
<GameRow
|
||||
key={g.id}
|
||||
game={g}
|
||||
busy={busy === g.id}
|
||||
onInstall={() => install(g.id)}
|
||||
onUninstall={() => uninstall(g.id)}
|
||||
onToggle={(v) => toggle(g.id, v)}
|
||||
/>
|
||||
))}
|
||||
{games.length === 0 && (
|
||||
<div className="text-muted text-sm">Ищем установленные игры…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DevPanel games={games} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GameRow({
|
||||
game,
|
||||
busy,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onToggle
|
||||
}: {
|
||||
game: GameStatus
|
||||
busy: boolean
|
||||
onInstall: () => void
|
||||
onUninstall: () => void
|
||||
onToggle: (v: boolean) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="font-semibold text-lg">{game.name}</h3>
|
||||
{game.installed ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium">
|
||||
Установлена
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-elevated text-muted font-medium">
|
||||
Не найдена
|
||||
</span>
|
||||
)}
|
||||
{game.integrationActive && game.launchOptionStatus === 'applied' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 font-medium inline-flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> Готово к работе
|
||||
</span>
|
||||
)}
|
||||
{game.integrationActive && game.launchOptionStatus === 'queued' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-500 font-medium inline-flex items-center gap-1">
|
||||
<Hourglass size={12} /> Ждём закрытия Steam
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{game.installPath && (
|
||||
<div className="text-xs text-muted mt-1 truncate font-mono">
|
||||
{game.installPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{game.installed && game.integrationActive && (
|
||||
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{game.integrationActive && game.launchOptionStatus === 'queued' && (
|
||||
<div className="mt-4 rounded-xl bg-amber-500/10 border border-amber-500/30 p-3 text-sm">
|
||||
Steam сейчас запущен. Параметр запуска{' '}
|
||||
<code className="px-1.5 py-0.5 rounded bg-surface-elevated text-accent font-mono text-xs">
|
||||
{game.launchOption}
|
||||
</code>{' '}
|
||||
пропишется автоматически при следующем закрытии Steam — ничего делать не
|
||||
нужно.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.integrationActive && game.launchOptionStatus === 'no_user' && (
|
||||
<div className="mt-4 rounded-xl bg-red-500/10 border border-red-500/30 p-3 text-sm">
|
||||
В Steam нет ни одного залогиненного аккаунта (отсутствует папка{' '}
|
||||
<code className="font-mono text-xs">userdata</code>). Запусти Steam один
|
||||
раз, чтобы он создал конфиг — потом снова нажми «Установить интеграцию».
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{game.installed && !game.integrationActive && (
|
||||
<Button onClick={onInstall} disabled={busy}>
|
||||
<Download size={16} /> Установить интеграцию
|
||||
</Button>
|
||||
)}
|
||||
{game.integrationActive && (
|
||||
<Button variant="secondary" onClick={onUninstall} disabled={busy}>
|
||||
<Trash2 size={16} /> Удалить интеграцию
|
||||
</Button>
|
||||
)}
|
||||
{!game.installed && (
|
||||
<div className="text-xs text-muted">
|
||||
Установи игру в Steam и нажми «Обновить»
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dota = games.find((g) => g.id === 'dota2')
|
||||
if (!dota?.enabled) return null
|
||||
return (
|
||||
<div className="mt-8 pt-6 border-t border-border/60">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs text-muted hover:text-text font-mono"
|
||||
>
|
||||
{open ? '▾' : '▸'} dev: симулировать конец матча
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{([
|
||||
{ label: '5 смертей', stats: { deaths: 5 } },
|
||||
{ label: '10 смертей', stats: { deaths: 10 } },
|
||||
{ label: '15 убийств', stats: { kills: 15 } },
|
||||
{ label: 'KDA 8/3/12', stats: { kills: 8, deaths: 3, assists: 12 } }
|
||||
] as { label: string; stats: Record<string, number> }[]).map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-surface-elevated hover:bg-border/60 text-text"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
src/renderer/src/pages/Settings.tsx
Normal file
164
src/renderer/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import type { NotificationMode, Settings as SettingsType, Theme } from '@shared/types'
|
||||
|
||||
export default function SettingsPage(): JSX.Element {
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
if (!settings) return <div className="p-8">Загрузка…</div>
|
||||
|
||||
const patch = (p: Partial<SettingsType>): void => {
|
||||
window.api.updateSettings(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 overflow-y-auto h-full max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-1">Настройки</h1>
|
||||
<p className="text-sm text-muted mb-8">Настройте поведение приложения</p>
|
||||
|
||||
<Section title="Напоминания">
|
||||
<SelectRow
|
||||
label="Режим уведомления"
|
||||
hint="Как должно выглядеть напоминание"
|
||||
value={settings.notificationMode}
|
||||
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
|
||||
options={[
|
||||
{ value: 'modal', label: 'Большое окно поверх всех' },
|
||||
{ value: 'toast', label: 'Тихое системное уведомление' },
|
||||
{ value: 'both', label: 'И окно, и уведомление' }
|
||||
]}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Звук уведомления"
|
||||
hint="Короткий сигнал при срабатывании"
|
||||
checked={settings.soundEnabled}
|
||||
onChange={(v) => patch({ soundEnabled: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Интервал кнопки «Отложить»"
|
||||
hint="На сколько минут откладывать при нажатии «Отложить»"
|
||||
value={String(settings.snoozeMinutes)}
|
||||
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
||||
options={[
|
||||
{ value: '1', label: '1 минута' },
|
||||
{ value: '5', label: '5 минут' },
|
||||
{ value: '10', label: '10 минут' },
|
||||
{ value: '15', label: '15 минут' },
|
||||
{ value: '30', label: '30 минут' }
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Окно и трей">
|
||||
<ToggleRow
|
||||
label="Сворачивать в трей"
|
||||
hint="При закрытии окна приложение остаётся работать в системном трее"
|
||||
checked={settings.minimizeToTray}
|
||||
onChange={(v) => patch({ minimizeToTray: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Запускать с Windows"
|
||||
hint="Открывать приложение автоматически при входе в систему"
|
||||
checked={settings.startWithWindows}
|
||||
onChange={(v) => patch({ startWithWindows: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Запускать свёрнутым"
|
||||
hint="При автозапуске открывать сразу в трее"
|
||||
checked={settings.startMinimized}
|
||||
onChange={(v) => patch({ startMinimized: v })}
|
||||
disabled={!settings.startWithWindows}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Внешний вид">
|
||||
<SelectRow
|
||||
label="Тема"
|
||||
value={settings.theme}
|
||||
onChange={(v) => patch({ theme: v as Theme })}
|
||||
options={[
|
||||
{ value: 'system', label: 'Как в системе' },
|
||||
{ value: 'light', label: 'Светлая' },
|
||||
{ value: 'dark', label: 'Тёмная' }
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xs uppercase tracking-wider text-muted font-semibold mb-3">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-2xl border border-border bg-surface overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{label}</div>
|
||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
options
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: { value: string; label: string }[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-4 border-b border-border/60 last:border-b-0">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{label}</div>
|
||||
{hint && <div className="text-xs text-muted mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 px-3 pr-8 rounded-lg border border-border bg-surface-elevated text-sm outline-none focus:border-accent"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/renderer/src/providers/ThemeProvider.tsx
Normal file
44
src/renderer/src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
|
||||
function hexToRgbString(hex: string): string {
|
||||
const cleaned = hex.replace('#', '').slice(0, 6).padEnd(6, '0')
|
||||
const r = parseInt(cleaned.slice(0, 2), 16)
|
||||
const g = parseInt(cleaned.slice(2, 4), 16)
|
||||
const b = parseInt(cleaned.slice(4, 6), 16)
|
||||
return `${r} ${g} ${b}`
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getOsTheme().then(setOsTheme)
|
||||
const unsub = window.api.onThemeChanged(setOsTheme)
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAccentColor().then((color) => {
|
||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
||||
document.documentElement.style.setProperty(
|
||||
'--accent-soft',
|
||||
hexToRgbString(color)
|
||||
)
|
||||
})
|
||||
const unsub = window.api.onAccentChanged((color) => {
|
||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const pref = settings?.theme ?? 'system'
|
||||
const effective = pref === 'system' ? osTheme : pref
|
||||
if (effective === 'dark') document.documentElement.classList.add('dark')
|
||||
else document.documentElement.classList.remove('dark')
|
||||
}, [settings?.theme, osTheme])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
40
src/renderer/src/store/appStore.ts
Normal file
40
src/renderer/src/store/appStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand'
|
||||
import type { AppState, Tick } from '@shared/types'
|
||||
|
||||
type TickMap = Record<string, Tick>
|
||||
|
||||
type Store = {
|
||||
state: AppState | null
|
||||
ticks: TickMap
|
||||
hydrated: boolean
|
||||
hydrate: () => Promise<void>
|
||||
setState: (s: AppState) => void
|
||||
setTicks: (ticks: Tick[]) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<Store>((set) => ({
|
||||
state: null,
|
||||
ticks: {},
|
||||
hydrated: false,
|
||||
hydrate: async () => {
|
||||
const s = await window.api.getState()
|
||||
set({ state: s, hydrated: true })
|
||||
},
|
||||
setState: (s) => set({ state: s }),
|
||||
setTicks: (ticks) => {
|
||||
const map: TickMap = {}
|
||||
for (const t of ticks) map[t.exerciseId] = t
|
||||
set({ ticks: map })
|
||||
}
|
||||
}))
|
||||
|
||||
export function subscribeToBackend(): () => void {
|
||||
const store = useAppStore.getState()
|
||||
store.hydrate()
|
||||
const u1 = window.api.onStateChanged((s) => useAppStore.getState().setState(s))
|
||||
const u2 = window.api.onTick((t) => useAppStore.getState().setTicks(t))
|
||||
return () => {
|
||||
u1()
|
||||
u2()
|
||||
}
|
||||
}
|
||||
93
src/renderer/src/styles/globals.css
Normal file
93
src/renderer/src/styles/globals.css
Normal file
@@ -0,0 +1,93 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Default accent (Windows blue), overridden at runtime via systemPreferences.getAccentColor */
|
||||
--accent: 91 141 239;
|
||||
--accent-soft: 91 141 239;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Light theme (default) */
|
||||
:root {
|
||||
--bg: 245 247 251;
|
||||
--surface: 255 255 255;
|
||||
--surface-elevated: 255 255 255;
|
||||
--border: 226 230 240;
|
||||
--text: 17 24 39;
|
||||
--muted: 107 114 128;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
.dark {
|
||||
--bg: 15 17 23;
|
||||
--surface: 24 27 35;
|
||||
--surface-elevated: 32 36 47;
|
||||
--border: 45 50 64;
|
||||
--text: 235 238 245;
|
||||
--muted: 148 156 173;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
background: rgb(var(--bg));
|
||||
color: rgb(var(--text));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom titlebar drag region */
|
||||
.titlebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
.titlebar-nodrag {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--muted) / 0.5);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgb(var(--accent) / 0.35);
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
/* Reminder-window root: rounded corners & subtle border */
|
||||
.reminder-shell {
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(var(--surface-elevated)) 0%,
|
||||
rgb(var(--surface)) 100%
|
||||
);
|
||||
box-shadow: 0 24px 60px -20px rgb(0 0 0 / 0.55);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
47
src/shared/ipc.ts
Normal file
47
src/shared/ipc.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const IPC = {
|
||||
getState: 'state:get',
|
||||
addExercise: 'exercise:add',
|
||||
updateExercise: 'exercise:update',
|
||||
deleteExercise: 'exercise:delete',
|
||||
toggleExercise: 'exercise:toggle',
|
||||
markDone: 'exercise:markDone',
|
||||
snooze: 'exercise:snooze',
|
||||
skip: 'exercise:skip',
|
||||
|
||||
updateSettings: 'settings:update',
|
||||
getAccentColor: 'system:accentColor',
|
||||
getOsTheme: 'system:osTheme',
|
||||
|
||||
pauseAll: 'app:pauseAll',
|
||||
resumeAll: 'app:resumeAll',
|
||||
quit: 'app:quit',
|
||||
minimizeMain: 'window:minimize',
|
||||
closeMain: 'window:close',
|
||||
hideMain: 'window:hide',
|
||||
|
||||
reminderClose: 'reminder:close',
|
||||
|
||||
// Games
|
||||
gamesList: 'games:list',
|
||||
gameInstall: 'games:install',
|
||||
gameUninstall: 'games:uninstall',
|
||||
gameToggle: 'games:toggle',
|
||||
gameOpenLaunchOptions: 'games:openLaunchOptions',
|
||||
|
||||
// Challenges
|
||||
addChallenge: 'challenge:add',
|
||||
updateChallenge: 'challenge:update',
|
||||
deleteChallenge: 'challenge:delete',
|
||||
toggleChallenge: 'challenge:toggle',
|
||||
markChallengeDone: 'challenge:markDone',
|
||||
closeMatchSummary: 'matchSummary:close',
|
||||
|
||||
// events from main → renderer
|
||||
evtTick: 'evt:tick',
|
||||
evtFire: 'evt:fire',
|
||||
evtMatchEnd: 'evt:matchEnd',
|
||||
evtStateChanged: 'evt:stateChanged',
|
||||
evtThemeChanged: 'evt:themeChanged',
|
||||
evtAccentChanged: 'evt:accentChanged',
|
||||
evtGamesChanged: 'evt:gamesChanged'
|
||||
} as const
|
||||
132
src/shared/types.ts
Normal file
132
src/shared/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export type Exercise = {
|
||||
id: string
|
||||
name: string
|
||||
reps: number
|
||||
icon: string
|
||||
intervalMinutes: number
|
||||
enabled: boolean
|
||||
nextFireAt: number
|
||||
lastDoneAt?: number
|
||||
}
|
||||
|
||||
export type NotificationMode = 'toast' | 'modal' | 'both'
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
export type Settings = {
|
||||
globalEnabled: boolean
|
||||
notificationMode: NotificationMode
|
||||
soundEnabled: boolean
|
||||
startWithWindows: boolean
|
||||
minimizeToTray: boolean
|
||||
startMinimized: boolean
|
||||
theme: Theme
|
||||
snoozeMinutes: number
|
||||
}
|
||||
|
||||
export type AppState = {
|
||||
exercises: Exercise[]
|
||||
settings: Settings
|
||||
challenges: Challenge[]
|
||||
gamesEnabled: Partial<Record<GameId, boolean>>
|
||||
}
|
||||
|
||||
export type Tick = {
|
||||
exerciseId: string
|
||||
msUntilFire: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type FireEvent = {
|
||||
exercise: Exercise
|
||||
mode: NotificationMode
|
||||
}
|
||||
|
||||
export type GameId = 'dota2'
|
||||
|
||||
export const GAME_STATS: Record<GameId, readonly GameStat[]> = {
|
||||
dota2: [
|
||||
'deaths',
|
||||
'kills',
|
||||
'assists',
|
||||
'last_hits',
|
||||
'denies',
|
||||
'duration_min'
|
||||
] as const
|
||||
}
|
||||
|
||||
export type GameStat =
|
||||
| 'deaths'
|
||||
| 'kills'
|
||||
| 'assists'
|
||||
| 'last_hits'
|
||||
| 'denies'
|
||||
| 'duration_min'
|
||||
|
||||
export const STAT_LABELS: Record<GameStat, string> = {
|
||||
deaths: 'смертей',
|
||||
kills: 'убийств',
|
||||
assists: 'ассистов',
|
||||
last_hits: 'ласт-хитов',
|
||||
denies: 'денаев',
|
||||
duration_min: 'минут матча'
|
||||
}
|
||||
|
||||
export type Challenge = {
|
||||
id: string
|
||||
name: string
|
||||
gameId: GameId
|
||||
stat: GameStat
|
||||
multiplier: number
|
||||
exerciseName: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type LaunchOptionStatus = 'applied' | 'queued' | 'no_user' | 'not_needed'
|
||||
|
||||
export type GameStatus = {
|
||||
id: GameId
|
||||
name: string
|
||||
installed: boolean
|
||||
installPath?: string
|
||||
integrationActive: boolean // cfg installed + listener running
|
||||
launchOption?: string // e.g. "-gamestateintegration"
|
||||
launchOptionStatus: LaunchOptionStatus
|
||||
steamRunning?: boolean // helps the UI explain queued state
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ChallengeResult = {
|
||||
challengeId: string
|
||||
name: string
|
||||
icon: string
|
||||
exerciseName: string
|
||||
reps: number
|
||||
statValue: number
|
||||
statLabel: string
|
||||
}
|
||||
|
||||
export type MatchSummary = {
|
||||
gameId: GameId
|
||||
gameName: string
|
||||
durationMs: number
|
||||
won?: boolean
|
||||
results: ChallengeResult[]
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
globalEnabled: true,
|
||||
notificationMode: 'modal',
|
||||
soundEnabled: true,
|
||||
startWithWindows: false,
|
||||
minimizeToTray: true,
|
||||
startMinimized: false,
|
||||
theme: 'system',
|
||||
snoozeMinutes: 5
|
||||
}
|
||||
|
||||
export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
||||
{ name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true },
|
||||
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
|
||||
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false }
|
||||
]
|
||||
Reference in New Issue
Block a user