feat: v1.1.0 — Epic Games detection, favorites, recent, UI improvements
- electron/epic.ts: auto-detect Epic Games from Manifests/*.item (skip DLCs) - electron/favorites.ts: persist favorites + recent (last 20) in userData - electron/steam.ts: replace winreg callbacks with reg query execSync + fallback paths - electron: new IPC handlers — favorites:get/toggle, recent:get; launch tracks recent - UI: star button on cards (favorites), sort A→Z/Z→A toggle, skeleton loading, fade-in card animation, Epic/Favorites/Recent filter tabs, SVG placeholder with initials Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface EpicGame {
|
||||
id: string
|
||||
name: string
|
||||
appName: string
|
||||
source: 'epic'
|
||||
image: string
|
||||
installed: true
|
||||
}
|
||||
|
||||
interface EpicManifest {
|
||||
DisplayName?: string
|
||||
AppName?: string
|
||||
MainGameAppName?: string
|
||||
bIsIncompleteInstall?: boolean
|
||||
AppCategories?: string[]
|
||||
}
|
||||
|
||||
const MANIFESTS_DIR = 'C:\\ProgramData\\Epic\\EpicGamesLauncher\\Data\\Manifests'
|
||||
|
||||
export function detectEpicGames(): EpicGame[] {
|
||||
if (process.platform !== 'win32') return []
|
||||
if (!existsSync(MANIFESTS_DIR)) return []
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(MANIFESTS_DIR)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const games: EpicGame[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.item')) continue
|
||||
|
||||
try {
|
||||
const raw = readFileSync(join(MANIFESTS_DIR, entry), 'utf-8')
|
||||
const m: EpicManifest = JSON.parse(raw)
|
||||
|
||||
const name = m.DisplayName
|
||||
const appName = m.AppName
|
||||
const mainApp = m.MainGameAppName
|
||||
|
||||
if (!name || !appName) continue
|
||||
if (m.bIsIncompleteInstall) continue
|
||||
// Skip DLCs
|
||||
if (mainApp && appName !== mainApp) continue
|
||||
// Only game categories
|
||||
const cats = m.AppCategories ?? []
|
||||
const isGame = cats.length === 0 || cats.some((c) => c.startsWith('games'))
|
||||
if (!isGame) continue
|
||||
|
||||
games.push({
|
||||
id: `epic_${appName}`,
|
||||
name,
|
||||
appName,
|
||||
source: 'epic',
|
||||
image: '',
|
||||
installed: true,
|
||||
})
|
||||
} catch {
|
||||
// skip bad manifests
|
||||
}
|
||||
}
|
||||
|
||||
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
return games
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
function readJson<T>(path: string, fallback: T): T {
|
||||
if (!existsSync(path)) return fallback
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function favPath(): string {
|
||||
return join(app.getPath('userData'), 'favorites.json')
|
||||
}
|
||||
|
||||
function recentPath(): string {
|
||||
return join(app.getPath('userData'), 'recent.json')
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
return readJson<string[]>(favPath(), [])
|
||||
}
|
||||
|
||||
export function toggleFavorite(id: string): string[] {
|
||||
const favs = getFavorites()
|
||||
const updated = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id]
|
||||
writeFileSync(favPath(), JSON.stringify(updated), 'utf-8')
|
||||
return updated
|
||||
}
|
||||
|
||||
export function getRecent(): string[] {
|
||||
return readJson<string[]>(recentPath(), [])
|
||||
}
|
||||
|
||||
export function addRecent(id: string): string[] {
|
||||
const recent = getRecent().filter((r) => r !== id)
|
||||
const updated = [id, ...recent].slice(0, 20)
|
||||
writeFileSync(recentPath(), JSON.stringify(updated), 'utf-8')
|
||||
return updated
|
||||
}
|
||||
@@ -5,6 +5,10 @@ export function launchSteamGame(appid: string): void {
|
||||
shell.openExternal(`steam://rungameid/${appid}`)
|
||||
}
|
||||
|
||||
export function launchEpicGame(appName: string): void {
|
||||
shell.openExternal(`com.epicgames.launcher://apps/${appName}?action=launch&silent=true`)
|
||||
}
|
||||
|
||||
export function launchExe(exe: string, args: string[] = []): void {
|
||||
const child = spawn(exe, args, {
|
||||
detached: true,
|
||||
|
||||
+25
-12
@@ -1,9 +1,11 @@
|
||||
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 { detectSteamGames } from './steam'
|
||||
import { detectEpicGames } from './epic'
|
||||
import { readCustomGames, writeCustomGames, CustomGame } from './config'
|
||||
import { launchSteamGame, launchExe } from './launcher'
|
||||
import { launchSteamGame, launchEpicGame, launchExe } from './launcher'
|
||||
import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
@@ -21,7 +23,7 @@ function createWindow(): void {
|
||||
preload: join(__dirname, '../preload/preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false, // allow loading steam CDN images and local file:// images
|
||||
webSecurity: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -39,19 +41,26 @@ function createWindow(): void {
|
||||
|
||||
// ── IPC Handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('games:get-all', async () => {
|
||||
const [steamGames, customGames] = await Promise.all([
|
||||
detectSteamGames(),
|
||||
Promise.resolve(readCustomGames()),
|
||||
])
|
||||
return [...steamGames, ...customGames]
|
||||
ipcMain.handle('games:get-all', () => {
|
||||
const steamGames = detectSteamGames()
|
||||
const epicGames = detectEpicGames()
|
||||
const customGames = readCustomGames()
|
||||
return [...steamGames, ...epicGames, ...customGames]
|
||||
})
|
||||
|
||||
ipcMain.handle('games:launch', (_event, id: string) => {
|
||||
if (id.startsWith('steam_')) {
|
||||
const appid = id.replace('steam_', '')
|
||||
launchSteamGame(appid)
|
||||
return { ok: true }
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
}
|
||||
|
||||
if (id.startsWith('epic_')) {
|
||||
const appName = id.replace('epic_', '')
|
||||
launchEpicGame(appName)
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
}
|
||||
|
||||
const customs = readCustomGames()
|
||||
@@ -60,7 +69,8 @@ ipcMain.handle('games:launch', (_event, id: string) => {
|
||||
|
||||
try {
|
||||
launchExe(game.exe, game.args)
|
||||
return { ok: true }
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
@@ -110,12 +120,15 @@ ipcMain.handle('dialog:pick-image', async () => {
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('favorites:get', () => getFavorites())
|
||||
ipcMain.handle('favorites:toggle', (_event, id: string) => toggleFavorite(id))
|
||||
ipcMain.handle('recent:get', () => getRecent())
|
||||
|
||||
// ── App lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
// Ctrl+Alt+A → open admin panel
|
||||
globalShortcut.register('CommandOrControl+Alt+A', () => {
|
||||
mainWindow?.webContents.send('admin:open')
|
||||
})
|
||||
|
||||
+5
-1
@@ -3,7 +3,8 @@ import type { CustomGame } from './config'
|
||||
|
||||
const launcher = {
|
||||
getGames: () => ipcRenderer.invoke('games:get-all'),
|
||||
launchGame: (id: string) => ipcRenderer.invoke('games:launch', id),
|
||||
launchGame: (id: string) =>
|
||||
ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>,
|
||||
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),
|
||||
@@ -13,6 +14,9 @@ const launcher = {
|
||||
ipcRenderer.on('admin:open', cb)
|
||||
return () => ipcRenderer.removeListener('admin:open', cb)
|
||||
},
|
||||
getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise<string[]>,
|
||||
toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise<string[]>,
|
||||
getRecent: () => ipcRenderer.invoke('recent:get') as Promise<string[]>,
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('launcher', launcher)
|
||||
|
||||
+38
-34
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs'
|
||||
import { execSync } from 'child_process'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface SteamGame {
|
||||
@@ -52,36 +53,44 @@ function parseVdf(content: string): Record<string, unknown> {
|
||||
return result
|
||||
}
|
||||
|
||||
async function getSteamPathFromRegistry(): Promise<string | null> {
|
||||
function getSteamPathFromRegistry(): 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',
|
||||
// Try reg.exe directly — simpler and reliable in packaged Electron apps
|
||||
const regPaths = [
|
||||
'HKLM\\SOFTWARE\\Wow6432Node\\Valve\\Steam',
|
||||
'HKLM\\SOFTWARE\\Valve\\Steam',
|
||||
'HKCU\\SOFTWARE\\Valve\\Steam',
|
||||
]
|
||||
|
||||
for (const regPath of regPaths) {
|
||||
try {
|
||||
const out = execSync(`reg query "${regPath}" /v InstallPath`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
})
|
||||
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
|
||||
const match = out.match(/InstallPath\s+REG_SZ\s+(.+)/i)
|
||||
if (match) {
|
||||
const p = match[1].trim()
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common default install locations
|
||||
const defaults = [
|
||||
'C:\\Program Files (x86)\\Steam',
|
||||
'C:\\Program Files\\Steam',
|
||||
join(process.env['LOCALAPPDATA'] ?? 'C:\\Users\\Public', 'Steam'),
|
||||
]
|
||||
for (const p of defaults) {
|
||||
if (existsSync(join(p, 'steam.exe'))) return p
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getLibraryFolders(steamPath: string): string[] {
|
||||
@@ -154,12 +163,9 @@ function scanSteamApps(steamappsDir: string): SteamGame[] {
|
||||
return games
|
||||
}
|
||||
|
||||
export async function detectSteamGames(): Promise<SteamGame[]> {
|
||||
const steamPath = await getSteamPathFromRegistry()
|
||||
if (!steamPath) {
|
||||
// On non-Windows or Steam not installed, return empty
|
||||
return []
|
||||
}
|
||||
export function detectSteamGames(): SteamGame[] {
|
||||
const steamPath = getSteamPathFromRegistry()
|
||||
if (!steamPath) return []
|
||||
|
||||
const libraryFolders = getLibraryFolders(steamPath)
|
||||
const games: SteamGame[] = []
|
||||
@@ -168,8 +174,6 @@ export async function detectSteamGames(): Promise<SteamGame[]> {
|
||||
games.push(...scanSteamApps(folder))
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
|
||||
return games
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "club-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "club-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@node-steam/vdf": "^2.0.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "club-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Game launcher for computer club",
|
||||
"author": "houseassassin",
|
||||
"main": "out/main/main.js",
|
||||
|
||||
+82
-34
@@ -1,20 +1,27 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Gamepad2, Settings, RefreshCw } from 'lucide-react'
|
||||
import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA } 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'
|
||||
import type { Category, CustomGame, Game, SortOrder } from './types'
|
||||
|
||||
export default function App() {
|
||||
const { games, loading, reload } = useGames()
|
||||
const [search, setSearch] = useState('')
|
||||
const [category, setCategory] = useState<Category>('all')
|
||||
const [sort, setSort] = useState<SortOrder>('name-asc')
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
const [adminMode, setAdminMode] = useState(false)
|
||||
const [favorites, setFavorites] = useState<string[]>([])
|
||||
const [recent, setRecent] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
window.launcher.getFavorites().then(setFavorites)
|
||||
window.launcher.getRecent().then(setRecent)
|
||||
}, [])
|
||||
|
||||
// Listen for Ctrl+Alt+A shortcut from main process
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.launcher.onAdminOpen(() => {
|
||||
setAdminOpen(true)
|
||||
@@ -23,34 +30,62 @@ export default function App() {
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = games
|
||||
if (category !== 'all') {
|
||||
list = list.filter((g) => g.source === category)
|
||||
const counts = useMemo((): Record<Category, number> => {
|
||||
const recentSet = new Set(recent)
|
||||
return {
|
||||
all: games.length,
|
||||
steam: games.filter((g) => g.source === 'steam').length,
|
||||
epic: games.filter((g) => g.source === 'epic').length,
|
||||
custom: games.filter((g) => g.source === 'custom').length,
|
||||
favorites: games.filter((g) => favorites.includes(g.id)).length,
|
||||
recent: games.filter((g) => recentSet.has(g.id)).length,
|
||||
}
|
||||
}, [games, favorites, recent])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list: Game[]
|
||||
|
||||
if (category === 'favorites') {
|
||||
list = games.filter((g) => favorites.includes(g.id))
|
||||
} else if (category === 'recent') {
|
||||
const recentMap = new Map(recent.map((id, i) => [id, i]))
|
||||
list = games.filter((g) => recentMap.has(g.id))
|
||||
list.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999))
|
||||
// Apply search but skip sort (recent order preserved)
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter((g) => g.name.toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
} else if (category !== 'all') {
|
||||
list = games.filter((g) => g.source === category)
|
||||
} else {
|
||||
list = [...games]
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
list.sort((a, b) =>
|
||||
sort === 'name-asc'
|
||||
? a.name.localeCompare(b.name, 'ru')
|
||||
: b.name.localeCompare(a.name, 'ru'),
|
||||
)
|
||||
|
||||
return list
|
||||
}, [games, category, search, sort, favorites, recent])
|
||||
|
||||
const handleLaunch = useCallback(async (id: string) => {
|
||||
await window.launcher.launchGame(id)
|
||||
const result = await window.launcher.launchGame(id)
|
||||
if (result.recent) setRecent(result.recent)
|
||||
}, [])
|
||||
|
||||
const handleEditGame = useCallback((game: Game) => {
|
||||
if (!adminOpen) setAdminOpen(true)
|
||||
}, [adminOpen])
|
||||
const handleToggleFavorite = useCallback(async (id: string) => {
|
||||
const updated = await window.launcher.toggleFavorite(id)
|
||||
setFavorites(updated)
|
||||
}, [])
|
||||
|
||||
const handleAdd = useCallback(async (payload: Omit<CustomGame, 'id' | 'source'>) => {
|
||||
await window.launcher.addCustomGame(payload)
|
||||
@@ -72,26 +107,41 @@ export default function App() {
|
||||
setAdminMode(false)
|
||||
}
|
||||
|
||||
const hasEpic = counts.epic > 0
|
||||
const hasFavorites = favorites.length > 0
|
||||
const hasRecent = recent.length > 0
|
||||
|
||||
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">
|
||||
<header className="flex items-center gap-3 px-6 py-3 bg-card border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 mr-1">
|
||||
<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} />
|
||||
<CategoryFilter
|
||||
active={category}
|
||||
counts={counts}
|
||||
hasEpic={hasEpic}
|
||||
hasFavorites={hasFavorites}
|
||||
hasRecent={hasRecent}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
|
||||
{/* Refresh */}
|
||||
{/* Sort toggle */}
|
||||
<button
|
||||
onClick={() => setSort((s) => (s === 'name-asc' ? 'name-desc' : 'name-asc'))}
|
||||
className="p-2 text-muted hover:text-text transition-colors"
|
||||
title={sort === 'name-asc' ? 'Сортировка А→Я' : 'Сортировка Я→А'}
|
||||
>
|
||||
{sort === 'name-asc' ? <ArrowDownAZ size={17} /> : <ArrowUpZA size={17} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
@@ -101,7 +151,6 @@ export default function App() {
|
||||
<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"
|
||||
@@ -111,16 +160,16 @@ export default function App() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Game grid */}
|
||||
<GameGrid
|
||||
games={filtered}
|
||||
loading={loading}
|
||||
favorites={favorites}
|
||||
onLaunch={handleLaunch}
|
||||
onEdit={handleEditGame}
|
||||
onEdit={() => { if (!adminOpen) setAdminOpen(true) }}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
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} игр`}
|
||||
@@ -128,7 +177,6 @@ export default function App() {
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
{/* Admin panel modal */}
|
||||
{adminOpen && (
|
||||
<AdminPanel
|
||||
games={games}
|
||||
|
||||
@@ -158,9 +158,10 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steam count info */}
|
||||
{/* Auto-detected info */}
|
||||
<p className="text-muted text-xs mb-4">
|
||||
Steam-игры ({games.filter((g) => g.source === 'steam').length}) определяются автоматически.
|
||||
Steam ({games.filter((g) => g.source === 'steam').length}) и Epic (
|
||||
{games.filter((g) => g.source === 'epic').length}) определяются автоматически.
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
||||
@@ -2,24 +2,30 @@ import type { Category } from '../types'
|
||||
|
||||
interface Props {
|
||||
active: Category
|
||||
counts: { all: number; steam: number; custom: number }
|
||||
counts: Record<Category, number>
|
||||
hasEpic: boolean
|
||||
hasFavorites: boolean
|
||||
hasRecent: boolean
|
||||
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, hasEpic, hasFavorites, hasRecent, onChange }: Props) {
|
||||
const tabs: { key: Category; label: string }[] = [
|
||||
{ key: 'all', label: 'Все' },
|
||||
{ key: 'steam', label: 'Steam' },
|
||||
...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []),
|
||||
{ key: 'custom', label: 'Добавленные' },
|
||||
...(hasFavorites ? [{ key: 'favorites' as Category, label: '⭐ Избранное' }] : []),
|
||||
...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []),
|
||||
]
|
||||
|
||||
export function CategoryFilter({ active, counts, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{TABS.map(({ key, label }) => (
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onChange(key)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active === key
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-card text-muted hover:text-text hover:bg-cardHover'
|
||||
|
||||
+71
-26
@@ -1,28 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Play, Settings } from 'lucide-react'
|
||||
import { Play, Settings, Star } 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"
|
||||
const COLORS = [
|
||||
['#1a1a3e', '#2d1b69'],
|
||||
['#1e3a5f', '#0d2137'],
|
||||
['#2d1b2e', '#4a1942'],
|
||||
['#1a2e1a', '#0d3b1a'],
|
||||
['#3b1a1a', '#260d0d'],
|
||||
]
|
||||
|
||||
interface Props {
|
||||
game: Game
|
||||
onLaunch: (id: string) => void
|
||||
onEdit?: (game: Game) => void
|
||||
adminMode?: boolean
|
||||
function nameHash(name: string): number {
|
||||
return name.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
|
||||
}
|
||||
|
||||
function makePlaceholder(name: string): string {
|
||||
const [c1, c2] = COLORS[nameHash(name) % COLORS.length]
|
||||
const initial = (name[0] ?? '?').toUpperCase()
|
||||
const safe = name.replace(/</g, '<').replace(/>/g, '>')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="215" viewBox="0 0 460 215">
|
||||
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${c1}"/>
|
||||
<stop offset="100%" stop-color="${c2}"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="460" height="215" fill="url(#g)"/>
|
||||
<text x="230" y="125" font-size="80" fill="white" fill-opacity="0.12" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
|
||||
<text x="230" y="165" font-size="13" fill="#94a3b8" text-anchor="middle" font-family="sans-serif">${safe}</text>
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
function getImageSrc(game: Game): string {
|
||||
if (game.source === 'steam') return game.headerUrl
|
||||
if (game.source === 'epic') return makePlaceholder(game.name)
|
||||
if (game.image) {
|
||||
// Local file path → file:// protocol
|
||||
if (game.image.startsWith('http')) return game.image
|
||||
return `file://${game.image.replace(/\\/g, '/')}`
|
||||
}
|
||||
return PLACEHOLDER
|
||||
return makePlaceholder(game.name)
|
||||
}
|
||||
|
||||
export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
|
||||
const SOURCE_BADGE: Record<string, { bg: string; text: string; label: string }> = {
|
||||
steam: { bg: 'bg-[#1b2838]', text: 'text-[#c7d5e0]', label: 'STEAM' },
|
||||
epic: { bg: 'bg-[#2b1c8a]/80', text: 'text-[#b0a0ff]', label: 'EPIC' },
|
||||
custom: { bg: 'bg-accent/20', text: 'text-accent', label: 'CUSTOM' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
game: Game
|
||||
index: number
|
||||
onLaunch: (id: string) => void
|
||||
onEdit?: (game: Game) => void
|
||||
onToggleFavorite?: (id: string) => void
|
||||
isFavorite?: boolean
|
||||
adminMode?: boolean
|
||||
}
|
||||
|
||||
export function GameCard({ game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
@@ -35,23 +69,17 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
const badge = SOURCE_BADGE[game.source]
|
||||
|
||||
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">
|
||||
<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 animate-fade-in"
|
||||
style={{ animationDelay: `${Math.min(index * 25, 400)}ms` }}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div className="aspect-[460/215] w-full overflow-hidden">
|
||||
<img
|
||||
src={imgError ? PLACEHOLDER : getImageSrc(game)}
|
||||
src={imgError ? makePlaceholder(game.name) : getImageSrc(game)}
|
||||
alt={game.name}
|
||||
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-75"
|
||||
onError={() => setImgError(true)}
|
||||
@@ -60,7 +88,24 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Source badge */}
|
||||
{srcBadge}
|
||||
<span className={`absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-semibold rounded select-none ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
|
||||
{/* Favorite button */}
|
||||
{!adminMode && onToggleFavorite && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleFavorite(game.id) }}
|
||||
className={`absolute top-2 right-2 p-1 rounded bg-black/60 transition-all duration-150 ${
|
||||
isFavorite
|
||||
? 'text-yellow-400 opacity-100'
|
||||
: 'text-white/50 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
<Star size={13} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Admin edit button */}
|
||||
{adminMode && onEdit && (
|
||||
@@ -72,7 +117,7 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hover overlay: game name + play button */}
|
||||
{/* Hover overlay: 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}
|
||||
@@ -87,7 +132,7 @@ export function GameCard({ game, onLaunch, onEdit, adminMode }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Always-visible title at bottom (when not hovering) */}
|
||||
{/* Always-visible title */}
|
||||
<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>
|
||||
|
||||
@@ -5,18 +5,29 @@ import type { Game } from '../types'
|
||||
interface Props {
|
||||
games: Game[]
|
||||
loading: boolean
|
||||
favorites: string[]
|
||||
onLaunch: (id: string) => void
|
||||
onEdit: (game: Game) => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
adminMode: boolean
|
||||
}
|
||||
|
||||
export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props) {
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden bg-card">
|
||||
<div className="aspect-[460/215] w-full bg-cardHover animate-shimmer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, 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 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">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -28,7 +39,7 @@ export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props)
|
||||
<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>
|
||||
<p className="text-sm text-muted/60">Убедитесь что Steam/Epic установлены или добавьте игры вручную</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -37,12 +48,15 @@ export function GameGrid({ games, loading, onLaunch, onEdit, adminMode }: Props)
|
||||
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) => (
|
||||
{games.map((game, i) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
index={i}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
isFavorite={favorites.includes(game.id)}
|
||||
adminMode={adminMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Game } from '../types'
|
||||
import type { CustomGame, 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>
|
||||
launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }>
|
||||
addCustomGame: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
||||
removeGame: (id: string) => Promise<{ ok: boolean }>
|
||||
updateGame: (game: import('../types').CustomGame) => Promise<{ ok: boolean }>
|
||||
updateGame: (game: CustomGame) => Promise<{ ok: boolean }>
|
||||
pickExe: () => Promise<string | null>
|
||||
pickImage: () => Promise<string | null>
|
||||
onAdminOpen: (cb: () => void) => () => void
|
||||
getFavorites: () => Promise<string[]>
|
||||
toggleFavorite: (id: string) => Promise<string[]>
|
||||
getRecent: () => Promise<string[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -7,6 +7,15 @@ export interface SteamGame {
|
||||
installed: true
|
||||
}
|
||||
|
||||
export interface EpicGame {
|
||||
id: string
|
||||
name: string
|
||||
appName: string
|
||||
source: 'epic'
|
||||
image: string
|
||||
installed: true
|
||||
}
|
||||
|
||||
export interface CustomGame {
|
||||
id: string
|
||||
name: string
|
||||
@@ -17,9 +26,11 @@ export interface CustomGame {
|
||||
source: 'custom'
|
||||
}
|
||||
|
||||
export type Game = SteamGame | CustomGame
|
||||
export type Game = SteamGame | EpicGame | CustomGame
|
||||
|
||||
export type Category = 'all' | 'steam' | 'custom'
|
||||
export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'favorites' | 'recent'
|
||||
|
||||
export type SortOrder = 'name-asc' | 'name-desc'
|
||||
|
||||
export interface GameFormData {
|
||||
name: string
|
||||
|
||||
@@ -18,6 +18,20 @@ export default {
|
||||
aspectRatio: {
|
||||
steam: '460 / 215',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.22s ease-out both',
|
||||
shimmer: 'shimmer 1.4s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%, 100%': { opacity: '0.4' },
|
||||
'50%': { opacity: '0.8' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user