feat: initial Club Launcher MVP

This commit is contained in:
2026-05-30 19:27:24 +00:00
commit 2ef7e2a92d
27 changed files with 8139 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist-build/
dist-electron/
dist-renderer/
out/
*.blockmap
+37
View File
@@ -0,0 +1,37 @@
module.exports = {
appId: 'ru.club.launcher',
productName: 'ClubLauncher',
executableName: 'ClubLauncher',
copyright: 'Copyright © 2026',
directories: {
output: 'dist-build',
buildResources: 'resources',
},
files: [
'out/**/*',
],
win: {
target: [
{ target: 'msi', arch: ['x64'] },
{ target: 'nsis', arch: ['x64'] },
],
forceCodeSigning: false,
signingHashAlgorithms: null,
sign: null,
},
msi: {
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Club Launcher',
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
installerIcon: 'resources/icon.ico',
uninstallerIcon: 'resources/icon.ico',
installerHeaderIcon: 'resources/icon.ico',
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Club Launcher',
},
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve('electron/main.ts'),
},
rollupOptions: {
external: ['winreg'],
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve('electron/preload.ts'),
},
},
},
renderer: {
plugins: [react()],
root: '.',
build: {
rollupOptions: {
input: resolve('index.html'),
},
},
},
})
+47
View File
@@ -0,0 +1,47 @@
import { app } from 'electron'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
import { join } from 'path'
export interface CustomGame {
id: string
name: string
exe: string
args: string[]
image: string
category: string
source: 'custom'
}
interface GamesConfig {
games: CustomGame[]
}
function getConfigPath(): string {
const dir = app.getPath('userData')
return join(dir, 'games.json')
}
export function readCustomGames(): CustomGame[] {
const path = getConfigPath()
if (!existsSync(path)) return []
try {
const raw = readFileSync(path, 'utf-8')
const data = JSON.parse(raw) as GamesConfig
return Array.isArray(data.games) ? data.games : []
} catch {
return []
}
}
export function writeCustomGames(games: CustomGame[]): void {
const path = getConfigPath()
const dir = path.substring(0, path.lastIndexOf(require('path').sep))
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const data: GamesConfig = { games }
writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8')
}
+15
View File
@@ -0,0 +1,15 @@
import { shell } from 'electron'
import { spawn } from 'child_process'
export function launchSteamGame(appid: string): void {
shell.openExternal(`steam://rungameid/${appid}`)
}
export function launchExe(exe: string, args: string[] = []): void {
const child = spawn(exe, args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
})
child.unref()
}
+131
View File
@@ -0,0 +1,131 @@
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
import { join } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { detectSteamGames, SteamGame } from './steam'
import { readCustomGames, writeCustomGames, CustomGame } from './config'
import { launchSteamGame, launchExe } from './launcher'
let mainWindow: BrowserWindow | null = null
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
backgroundColor: '#0f0f0f',
title: 'Club Launcher',
frame: true,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false, // allow loading steam CDN images and local file:// images
},
})
if (process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// ── IPC Handlers ────────────────────────────────────────────────────────────
ipcMain.handle('games:get-all', async () => {
const [steamGames, customGames] = await Promise.all([
detectSteamGames(),
Promise.resolve(readCustomGames()),
])
return [...steamGames, ...customGames]
})
ipcMain.handle('games:launch', (_event, id: string) => {
if (id.startsWith('steam_')) {
const appid = id.replace('steam_', '')
launchSteamGame(appid)
return { ok: true }
}
const customs = readCustomGames()
const game = customs.find((g) => g.id === id)
if (!game) return { ok: false, error: 'Game not found' }
try {
launchExe(game.exe, game.args)
return { ok: true }
} catch (e) {
return { ok: false, error: String(e) }
}
})
ipcMain.handle('admin:add-game', (_event, game: Omit<CustomGame, 'id' | 'source'>) => {
const customs = readCustomGames()
const newGame: CustomGame = {
...game,
id: uuidv4(),
source: 'custom',
args: game.args ?? [],
image: game.image ?? '',
category: game.category ?? 'Other',
}
writeCustomGames([...customs, newGame])
return newGame
})
ipcMain.handle('admin:remove-game', (_event, id: string) => {
const customs = readCustomGames()
writeCustomGames(customs.filter((g) => g.id !== id))
return { ok: true }
})
ipcMain.handle('admin:update-game', (_event, updated: CustomGame) => {
const customs = readCustomGames()
writeCustomGames(customs.map((g) => (g.id === updated.id ? { ...g, ...updated } : g)))
return { ok: true }
})
ipcMain.handle('dialog:pick-exe', async () => {
const result = await dialog.showOpenDialog({
title: 'Выберите исполняемый файл',
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd'] }],
properties: ['openFile'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('dialog:pick-image', async () => {
const result = await dialog.showOpenDialog({
title: 'Выберите обложку',
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
properties: ['openFile'],
})
return result.canceled ? null : result.filePaths[0]
})
// ── App lifecycle ────────────────────────────────────────────────────────────
app.whenReady().then(() => {
createWindow()
// Ctrl+Alt+A → open admin panel
globalShortcut.register('CommandOrControl+Alt+A', () => {
mainWindow?.webContents.send('admin:open')
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
globalShortcut.unregisterAll()
if (process.platform !== 'darwin') app.quit()
})
+20
View File
@@ -0,0 +1,20 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { CustomGame } from './config'
const launcher = {
getGames: () => ipcRenderer.invoke('games:get-all'),
launchGame: (id: string) => ipcRenderer.invoke('games:launch', id),
addCustomGame: (game: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', game),
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
updateGame: (game: CustomGame) => ipcRenderer.invoke('admin:update-game', game),
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise<string | null>,
onAdminOpen: (cb: () => void) => {
ipcRenderer.on('admin:open', cb)
return () => ipcRenderer.removeListener('admin:open', cb)
},
}
contextBridge.exposeInMainWorld('launcher', launcher)
export type LauncherAPI = typeof launcher
+175
View File
@@ -0,0 +1,175 @@
import { existsSync, readdirSync, readFileSync } from 'fs'
import { join } from 'path'
export interface SteamGame {
id: string
name: string
appid: string
headerUrl: string
source: 'steam'
installed: true
}
function parseVdf(content: string): Record<string, unknown> {
// Minimal VDF parser for libraryfolders.vdf and appmanifest ACF files
const result: Record<string, unknown> = {}
const lines = content.split(/\r?\n/)
const stack: Array<Record<string, unknown>> = [result]
let key: string | null = null
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith('//')) continue
const tokenMatch = line.match(/^"([^"]*)"/)
if (!tokenMatch) {
if (line === '{') {
const obj: Record<string, unknown> = {}
if (key !== null) {
stack[stack.length - 1][key] = obj
}
stack.push(obj)
key = null
} else if (line === '}') {
stack.pop()
key = null
}
continue
}
if (key === null) {
key = tokenMatch[1]
} else {
// value
const valueMatch = line.match(/^"[^"]*"\s+"([^"]*)"/)
if (valueMatch) {
stack[stack.length - 1][key] = valueMatch[1]
}
key = null
}
}
return result
}
async function getSteamPathFromRegistry(): Promise<string | null> {
if (process.platform !== 'win32') return null
try {
// Dynamic require to avoid issues on non-Windows
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Registry = require('winreg') as typeof import('winreg')
return new Promise((resolve) => {
const key = new Registry({
hive: Registry.HKLM,
key: '\\SOFTWARE\\Wow6432Node\\Valve\\Steam',
})
key.get('InstallPath', (err, item) => {
if (err || !item) {
// Try 32-bit key
const key32 = new Registry({
hive: Registry.HKLM,
key: '\\SOFTWARE\\Valve\\Steam',
})
key32.get('InstallPath', (err2, item2) => {
resolve(err2 || !item2 ? null : item2.value)
})
} else {
resolve(item.value)
}
})
})
} catch {
return null
}
}
function getLibraryFolders(steamPath: string): string[] {
const folders: string[] = [join(steamPath, 'steamapps')]
const vdfPath = join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!existsSync(vdfPath)) return folders
try {
const content = readFileSync(vdfPath, 'utf-8')
const parsed = parseVdf(content)
// Support both old (numbered keys) and new (nested "path") format
const root = (parsed['libraryfolders'] ?? parsed['LibraryFolders'] ?? parsed) as Record<string, unknown>
for (const [, val] of Object.entries(root)) {
if (typeof val === 'string' && existsSync(val)) {
folders.push(join(val, 'steamapps'))
} else if (typeof val === 'object' && val !== null) {
const nested = val as Record<string, unknown>
if (typeof nested['path'] === 'string' && existsSync(nested['path'])) {
folders.push(join(nested['path'], 'steamapps'))
}
}
}
} catch {
// ignore parse errors
}
return [...new Set(folders)]
}
function scanSteamApps(steamappsDir: string): SteamGame[] {
if (!existsSync(steamappsDir)) return []
const games: SteamGame[] = []
let entries: string[]
try {
entries = readdirSync(steamappsDir)
} catch {
return []
}
for (const entry of entries) {
if (!entry.startsWith('appmanifest_') || !entry.endsWith('.acf')) continue
try {
const content = readFileSync(join(steamappsDir, entry), 'utf-8')
const parsed = parseVdf(content)
const app = (parsed['AppState'] ?? parsed) as Record<string, unknown>
const appid = String(app['appid'] ?? '')
const name = String(app['name'] ?? '')
if (!appid || !name || name === 'Steamworks Common Redistributables') continue
games.push({
id: `steam_${appid}`,
name,
appid,
headerUrl: `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`,
source: 'steam',
installed: true,
})
} catch {
// skip bad manifests
}
}
return games
}
export async function detectSteamGames(): Promise<SteamGame[]> {
const steamPath = await getSteamPathFromRegistry()
if (!steamPath) {
// On non-Windows or Steam not installed, return empty
return []
}
const libraryFolders = getLibraryFolders(steamPath)
const games: SteamGame[] = []
for (const folder of libraryFolders) {
games.push(...scanSteamApps(folder))
}
// Sort alphabetically
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
return games
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Club Launcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6784
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "club-launcher",
"version": "1.0.0",
"description": "Game launcher for computer club",
"author": "houseassassin",
"main": "out/main/main.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build && electron-builder",
"build:win": "electron-vite build && electron-builder --win",
"preview": "electron-vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.441.0",
"uuid": "^9.0.1",
"@node-steam/vdf": "^2.0.1"
},
"devDependencies": {
"electron": "^31.0.0",
"electron-vite": "^2.3.0",
"electron-builder": "^24.13.3",
"vite": "^5.3.1",
"typescript": "^5.5.3",
"tailwindcss": "^3.4.7",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"winreg": "^1.2.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"@types/winreg": "^1.2.36",
"@vitejs/plugin-react": "^4.3.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+143
View File
@@ -0,0 +1,143 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Gamepad2, Settings, RefreshCw } from 'lucide-react'
import { useGames } from './hooks/useGames'
import { GameGrid } from './components/GameGrid'
import { SearchBar } from './components/SearchBar'
import { CategoryFilter } from './components/CategoryFilter'
import { AdminPanel } from './components/AdminPanel'
import type { Category, CustomGame, Game } from './types'
export default function App() {
const { games, loading, reload } = useGames()
const [search, setSearch] = useState('')
const [category, setCategory] = useState<Category>('all')
const [adminOpen, setAdminOpen] = useState(false)
const [adminMode, setAdminMode] = useState(false)
// Listen for Ctrl+Alt+A shortcut from main process
useEffect(() => {
const unsubscribe = window.launcher.onAdminOpen(() => {
setAdminOpen(true)
setAdminMode(true)
})
return unsubscribe
}, [])
const filtered = useMemo(() => {
let list = games
if (category !== 'all') {
list = list.filter((g) => g.source === category)
}
if (search) {
const q = search.toLowerCase()
list = list.filter((g) => g.name.toLowerCase().includes(q))
}
return list
}, [games, category, search])
const counts = useMemo(
() => ({
all: games.length,
steam: games.filter((g) => g.source === 'steam').length,
custom: games.filter((g) => g.source === 'custom').length,
}),
[games],
)
const handleLaunch = useCallback(async (id: string) => {
await window.launcher.launchGame(id)
}, [])
const handleEditGame = useCallback((game: Game) => {
if (!adminOpen) setAdminOpen(true)
}, [adminOpen])
const handleAdd = useCallback(async (payload: Omit<CustomGame, 'id' | 'source'>) => {
await window.launcher.addCustomGame(payload)
await reload()
}, [reload])
const handleRemove = useCallback(async (id: string) => {
await window.launcher.removeGame(id)
await reload()
}, [reload])
const handleUpdate = useCallback(async (game: CustomGame) => {
await window.launcher.updateGame(game)
await reload()
}, [reload])
const closeAdmin = () => {
setAdminOpen(false)
setAdminMode(false)
}
return (
<div className="flex flex-col h-screen bg-bg text-text overflow-hidden">
{/* Header */}
<header className="flex items-center gap-4 px-6 py-3 bg-card border-b border-border shrink-0">
{/* Logo */}
<div className="flex items-center gap-2 mr-2">
<Gamepad2 size={22} className="text-accent" />
<span className="font-bold text-base tracking-wide">Club Launcher</span>
</div>
{/* Category filter */}
<CategoryFilter active={category} counts={counts} onChange={setCategory} />
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
<SearchBar value={search} onChange={setSearch} />
{/* Refresh */}
<button
onClick={reload}
disabled={loading}
className="p-2 text-muted hover:text-text transition-colors disabled:opacity-40"
title="Обновить библиотеку"
>
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
</button>
{/* Admin toggle */}
<button
onClick={() => { setAdminOpen(true); setAdminMode(true) }}
className="p-2 text-muted hover:text-accent transition-colors"
title="Управление играми"
>
<Settings size={17} />
</button>
</header>
{/* Game grid */}
<GameGrid
games={filtered}
loading={loading}
onLaunch={handleLaunch}
onEdit={handleEditGame}
adminMode={adminMode}
/>
{/* Status bar */}
<footer className="px-6 py-2 bg-card border-t border-border shrink-0">
<p className="text-muted text-xs">
{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} игр`}
{adminMode && <span className="ml-3 text-accent"> Режим администратора</span>}
</p>
</footer>
{/* Admin panel modal */}
{adminOpen && (
<AdminPanel
games={games}
onClose={closeAdmin}
onAdd={handleAdd}
onRemove={handleRemove}
onUpdate={handleUpdate}
/>
)}
</div>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useState } from 'react'
import { X, Plus, Trash2, Edit2, FolderOpen, Image } from 'lucide-react'
import type { CustomGame, Game, GameFormData } from '../types'
const CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
const EMPTY_FORM: GameFormData = {
name: '',
exe: '',
args: '',
image: '',
category: 'Other',
}
interface Props {
games: Game[]
onClose: () => void
onAdd: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<void>
onRemove: (id: string) => Promise<void>
onUpdate: (game: CustomGame) => Promise<void>
}
type Mode = 'list' | 'add' | 'edit'
export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) {
const [mode, setMode] = useState<Mode>('list')
const [form, setForm] = useState<GameFormData>(EMPTY_FORM)
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const customGames = games.filter((g): g is CustomGame => g.source === 'custom')
const startAdd = () => {
setForm(EMPTY_FORM)
setEditId(null)
setError(null)
setMode('add')
}
const startEdit = (game: CustomGame) => {
setForm({
name: game.name,
exe: game.exe,
args: game.args.join(' '),
image: game.image,
category: game.category,
})
setEditId(game.id)
setError(null)
setMode('edit')
}
const pickExe = async () => {
const path = await window.launcher.pickExe()
if (path) setForm((f) => ({ ...f, exe: path }))
}
const pickImage = async () => {
const path = await window.launcher.pickImage()
if (path) setForm((f) => ({ ...f, image: path }))
}
const handleSave = async () => {
if (!form.name.trim()) { setError('Введите название'); return }
if (!form.exe.trim()) { setError('Выберите исполняемый файл'); return }
setSaving(true)
setError(null)
try {
const payload = {
name: form.name.trim(),
exe: form.exe.trim(),
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
image: form.image.trim(),
category: form.category,
}
if (mode === 'edit' && editId) {
await onUpdate({ ...payload, id: editId, source: 'custom' })
} else {
await onAdd(payload)
}
setMode('list')
} catch (e) {
setError(String(e))
} finally {
setSaving(false)
}
}
const handleRemove = async (id: string, name: string) => {
if (!confirm(`Удалить «${name}»?`)) return
await onRemove(id)
}
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h2 className="text-text font-semibold text-lg">
{mode === 'list' ? 'Управление играми' : mode === 'add' ? 'Добавить игру' : 'Редактировать игру'}
</h2>
<p className="text-muted text-xs mt-0.5">Admin Panel · Ctrl+Alt+A</p>
</div>
<button onClick={onClose} className="text-muted hover:text-text transition-colors p-1">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{mode === 'list' ? (
<div className="p-6">
{/* Custom games list */}
{customGames.length === 0 ? (
<p className="text-muted text-sm text-center py-8">Нет добавленных игр</p>
) : (
<div className="space-y-2 mb-4">
{customGames.map((game) => (
<div
key={game.id}
className="flex items-center gap-3 p-3 bg-bg rounded-lg border border-border"
>
{game.image ? (
<img
src={game.image.startsWith('http') ? game.image : `file://${game.image.replace(/\\/g, '/')}`}
alt={game.name}
className="w-14 h-7 object-cover rounded shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="w-14 h-7 bg-border rounded shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-text text-sm font-medium truncate">{game.name}</p>
<p className="text-muted text-xs truncate">{game.exe}</p>
</div>
<span className="text-xs text-muted/70 px-2 py-0.5 bg-border rounded shrink-0">
{game.category}
</span>
<button
onClick={() => startEdit(game)}
className="text-muted hover:text-accent transition-colors p-1 shrink-0"
>
<Edit2 size={15} />
</button>
<button
onClick={() => handleRemove(game.id, game.name)}
className="text-muted hover:text-red-400 transition-colors p-1 shrink-0"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
{/* Steam count info */}
<p className="text-muted text-xs mb-4">
Steam-игры ({games.filter((g) => g.source === 'steam').length}) определяются автоматически.
</p>
<button
onClick={startAdd}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accentHover text-white rounded-lg text-sm font-medium transition-colors"
>
<Plus size={16} />
Добавить игру
</button>
</div>
) : (
/* Add / Edit form */
<div className="p-6 space-y-4">
{/* Name */}
<div>
<label className="text-text text-sm mb-1 block">Название *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Counter-Strike 2"
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
/>
</div>
{/* Exe */}
<div>
<label className="text-text text-sm mb-1 block">Исполняемый файл *</label>
<div className="flex gap-2">
<input
type="text"
value={form.exe}
onChange={(e) => setForm((f) => ({ ...f, exe: e.target.value }))}
placeholder="C:\Games\game.exe"
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
/>
<button
onClick={pickExe}
className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors"
title="Выбрать файл"
>
<FolderOpen size={16} />
</button>
</div>
</div>
{/* Args */}
<div>
<label className="text-text text-sm mb-1 block">Аргументы запуска <span className="text-muted">(необязательно)</span></label>
<input
type="text"
value={form.args}
onChange={(e) => setForm((f) => ({ ...f, args: e.target.value }))}
placeholder="-windowed -nosplash"
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
/>
</div>
{/* Image */}
<div>
<label className="text-text text-sm mb-1 block">Обложка <span className="text-muted">(необязательно)</span></label>
<div className="flex gap-2">
<input
type="text"
value={form.image}
onChange={(e) => setForm((f) => ({ ...f, image: e.target.value }))}
placeholder="C:\Games\cover.jpg или https://..."
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
/>
<button
onClick={pickImage}
className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors"
title="Выбрать изображение"
>
<Image size={16} />
</button>
</div>
{form.image && (
<img
src={form.image.startsWith('http') ? form.image : `file://${form.image.replace(/\\/g, '/')}`}
alt="preview"
className="mt-2 h-16 rounded object-cover border border-border"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
)}
</div>
{/* Category */}
<div>
<label className="text-text text-sm mb-1 block">Категория</label>
<select
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm focus:outline-none focus:border-accent transition-colors"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
</div>
)}
</div>
{/* Footer for form modes */}
{mode !== 'list' && (
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button
onClick={() => { setMode('list'); setError(null) }}
className="px-4 py-2 text-muted hover:text-text text-sm transition-colors"
>
Отмена
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 bg-accent hover:bg-accentHover disabled:bg-accent/50 text-white rounded-lg text-sm font-medium transition-colors"
>
{saving ? 'Сохранение...' : mode === 'add' ? 'Добавить' : 'Сохранить'}
</button>
</div>
)}
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { Category } from '../types'
interface Props {
active: Category
counts: { all: number; steam: number; custom: number }
onChange: (c: Category) => void
}
const TABS: { key: Category; label: string }[] = [
{ key: 'all', label: 'Все' },
{ key: 'steam', label: 'Steam' },
{ key: 'custom', label: 'Добавленные' },
]
export function CategoryFilter({ active, counts, onChange }: Props) {
return (
<div className="flex gap-1">
{TABS.map(({ key, label }) => (
<button
key={key}
onClick={() => onChange(key)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
active === key
? 'bg-accent text-white'
: 'bg-card text-muted hover:text-text hover:bg-cardHover'
}`}
>
{label}
<span className={`ml-1.5 text-xs ${active === key ? 'text-white/70' : 'text-muted/70'}`}>
{counts[key]}
</span>
</button>
))}
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Play, Settings } from 'lucide-react'
import type { Game } from '../types'
const PLACEHOLDER =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='460' height='215' viewBox='0 0 460 215'%3E%3Crect width='460' height='215' fill='%231a1a2e'/%3E%3Ctext x='230' y='115' font-size='14' fill='%2364748b' text-anchor='middle' font-family='sans-serif'%3EНет обложки%3C/text%3E%3C/svg%3E"
interface Props {
game: Game
onLaunch: (id: string) => void
onEdit?: (game: Game) => void
adminMode?: boolean
}
function getImageSrc(game: Game): string {
if (game.source === 'steam') return game.headerUrl
if (game.image) {
// Local file path → file:// protocol
if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}`
}
return PLACEHOLDER
}
export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
const [imgError, setImgError] = useState(false)
const [launching, setLaunching] = useState(false)
const handleLaunch = async () => {
setLaunching(true)
try {
await onLaunch(game.id)
} finally {
setTimeout(() => setLaunching(false), 2000)
}
}
const srcBadge =
game.source === 'steam' ? (
<span className="absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-semibold rounded bg-[#1b2838] text-[#c7d5e0] select-none">
STEAM
</span>
) : (
<span className="absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-semibold rounded bg-accent/20 text-accent select-none">
CUSTOM
</span>
)
return (
<div className="group relative rounded-lg overflow-hidden bg-card cursor-pointer select-none transition-transform duration-200 hover:scale-105 hover:shadow-2xl hover:shadow-black/60">
{/* Cover image */}
<div className="aspect-[460/215] w-full overflow-hidden">
<img
src={imgError ? PLACEHOLDER : getImageSrc(game)}
alt={game.name}
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-75"
onError={() => setImgError(true)}
loading="lazy"
/>
</div>
{/* Source badge */}
{srcBadge}
{/* Admin edit button */}
{adminMode && onEdit && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(game) }}
className="absolute top-2 right-2 p-1 rounded bg-black/60 text-muted hover:text-text transition-colors"
>
<Settings size={14} />
</button>
)}
{/* Hover overlay: game name + play button */}
<div className="absolute inset-0 flex flex-col justify-end p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<p className="text-white font-semibold text-sm leading-tight mb-2 drop-shadow-lg line-clamp-2">
{game.name}
</p>
<button
onClick={handleLaunch}
disabled={launching}
className="flex items-center justify-center gap-2 w-full py-2 rounded bg-accent hover:bg-accentHover disabled:bg-accent/50 text-white font-semibold text-sm transition-colors"
>
<Play size={14} fill="white" />
{launching ? 'Запуск...' : 'Играть'}
</button>
</div>
{/* Always-visible title at bottom (when not hovering) */}
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-gradient-to-t from-black/80 to-transparent group-hover:opacity-0 transition-opacity duration-200">
<p className="text-white text-xs font-medium truncate drop-shadow">{game.name}</p>
</div>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { Gamepad2 } from 'lucide-react'
import { GameCard } from './GameCard'
import type { Game } from '../types'
interface Props {
games: Game[]
loading: boolean
onLaunch: (id: string) => void
onEdit: (game: Game) => void
adminMode: boolean
}
export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props) {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4 text-muted">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
<p className="text-sm">Загрузка библиотеки...</p>
</div>
</div>
)
}
if (games.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted">
<Gamepad2 size={48} strokeWidth={1} />
<p className="text-base">Игры не найдены</p>
<p className="text-sm text-muted/60">Убедитесь что Steam установлен или добавьте игры вручную</p>
</div>
</div>
)
}
return (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onLaunch={onLaunch}
onEdit={onEdit}
adminMode={adminMode}
/>
))}
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import { Search, X } from 'lucide-react'
interface Props {
value: string
onChange: (val: string) => void
}
export function SearchBar({ value, onChange }: Props) {
const [local, setLocal] = useState(value)
// Debounce
useEffect(() => {
const t = setTimeout(() => onChange(local), 300)
return () => clearTimeout(t)
}, [local, onChange])
return (
<div className="relative flex items-center">
<Search size={16} className="absolute left-3 text-muted pointer-events-none" />
<input
type="text"
value={local}
onChange={(e) => setLocal(e.target.value)}
placeholder="Поиск игры..."
className="pl-9 pr-9 py-2 bg-card border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors w-64"
/>
{local && (
<button
onClick={() => { setLocal(''); onChange('') }}
className="absolute right-3 text-muted hover:text-text transition-colors"
>
<X size={14} />
</button>
)}
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useState, useEffect, useCallback } from 'react'
import type { Game } from '../types'
declare global {
interface Window {
launcher: {
getGames: () => Promise<Game[]>
launchGame: (id: string) => Promise<{ ok: boolean; error?: string }>
addCustomGame: (game: Omit<import('../types').CustomGame, 'id' | 'source'>) => Promise<import('../types').CustomGame>
removeGame: (id: string) => Promise<{ ok: boolean }>
updateGame: (game: import('../types').CustomGame) => Promise<{ ok: boolean }>
pickExe: () => Promise<string | null>
pickImage: () => Promise<string | null>
onAdminOpen: (cb: () => void) => () => void
}
}
}
export function useGames() {
const [games, setGames] = useState<Game[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await window.launcher.getGames()
setGames(data)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return { games, loading, error, reload: load }
}
+36
View File
@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background-color: #0f0f0f;
color: #e2e8f0;
font-family: -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0f0f0f;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
const root = document.getElementById('root')!
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
+30
View File
@@ -0,0 +1,30 @@
export interface SteamGame {
id: string
name: string
appid: string
headerUrl: string
source: 'steam'
installed: true
}
export interface CustomGame {
id: string
name: string
exe: string
args: string[]
image: string
category: string
source: 'custom'
}
export type Game = SteamGame | CustomGame
export type Category = 'all' | 'steam' | 'custom'
export interface GameFormData {
name: string
exe: string
args: string
image: string
category: string
}
+24
View File
@@ -0,0 +1,24 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./src/**/*.{ts,tsx}', './index.html'],
theme: {
extend: {
colors: {
bg: '#0f0f0f',
card: '#1a1a2e',
cardHover: '#16213e',
accent: '#22c55e',
accentHover: '#16a34a',
surface: '#0f3460',
text: '#e2e8f0',
muted: '#64748b',
border: '#1e293b',
},
aspectRatio: {
steam: '460 / 215',
},
},
},
plugins: [],
} satisfies Config
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"composite": true,
"moduleResolution": "bundler",
"target": "ES2022",
"module": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist-electron",
"types": ["node", "winreg"]
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"composite": true,
"moduleResolution": "bundler",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}