feat: initial Club Launcher MVP
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist-build/
|
||||
dist-electron/
|
||||
dist-renderer/
|
||||
out/
|
||||
*.blockmap
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
Generated
+6784
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+143
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user