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:
2026-06-02 12:28:52 +00:00
parent 2ef7e2a92d
commit 35d46f532a
16 changed files with 414 additions and 134 deletions
+71
View File
@@ -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
}
+42
View File
@@ -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
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+3 -2
View File
@@ -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
+15 -9
View File
@@ -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
View File
@@ -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, '&lt;').replace(/>/g, '&gt;')
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>
+21 -7
View File
@@ -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}
/>
))}
+7 -4
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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: [],