Compare commits
2 Commits
35d46f532a
...
b84a7bdf29
| Author | SHA1 | Date | |
|---|---|---|---|
| b84a7bdf29 | |||
| 35c61c2496 |
@@ -4,3 +4,4 @@ dist-electron/
|
||||
dist-renderer/
|
||||
out/
|
||||
*.blockmap
|
||||
dist/
|
||||
|
||||
+77
-19
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { join, sep } from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export interface CustomGame {
|
||||
id: string
|
||||
@@ -12,36 +13,93 @@ export interface CustomGame {
|
||||
source: 'custom'
|
||||
}
|
||||
|
||||
interface GamesConfig {
|
||||
games: CustomGame[]
|
||||
export interface AppEntry {
|
||||
id: string
|
||||
name: string
|
||||
exe: string
|
||||
args: string[]
|
||||
image: string
|
||||
category: string
|
||||
source: 'app'
|
||||
}
|
||||
|
||||
function getConfigPath(): string {
|
||||
function getUserDataPath(filename: string): string {
|
||||
const dir = app.getPath('userData')
|
||||
return join(dir, 'games.json')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
return join(dir, filename)
|
||||
}
|
||||
|
||||
function readJsonList<T>(filename: string): T[] {
|
||||
const path = getUserDataPath(filename)
|
||||
if (!existsSync(path)) return []
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
return Array.isArray(data?.items) ? data.items : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonList<T>(filename: string, items: T[]): void {
|
||||
writeFileSync(getUserDataPath(filename), JSON.stringify({ items }, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
// ── Custom games ─────────────────────────────────────────────────────────
|
||||
|
||||
export function readCustomGames(): CustomGame[] {
|
||||
const path = getConfigPath()
|
||||
// Support both old format (games.json with { games: [] }) and new format
|
||||
const path = getUserDataPath('games.json')
|
||||
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 : []
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
if (Array.isArray(data?.games)) return data.games // legacy
|
||||
if (Array.isArray(data?.items)) return data.items // new
|
||||
return []
|
||||
} 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')
|
||||
writeFileSync(getUserDataPath('games.json'), JSON.stringify({ items: games }, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function addCustomGame(data: Omit<CustomGame, 'id' | 'source'>): CustomGame {
|
||||
const games = readCustomGames()
|
||||
const newGame: CustomGame = { ...data, id: uuidv4(), source: 'custom' }
|
||||
writeCustomGames([...games, newGame])
|
||||
return newGame
|
||||
}
|
||||
|
||||
export function removeCustomGame(id: string): void {
|
||||
writeCustomGames(readCustomGames().filter((g) => g.id !== id))
|
||||
}
|
||||
|
||||
export function updateCustomGame(updated: CustomGame): void {
|
||||
writeCustomGames(readCustomGames().map((g) => (g.id === updated.id ? { ...g, ...updated } : g)))
|
||||
}
|
||||
|
||||
// ── App entries ──────────────────────────────────────────────────────────
|
||||
|
||||
export function readApps(): AppEntry[] {
|
||||
return readJsonList<AppEntry>('apps.json')
|
||||
}
|
||||
|
||||
export function writeApps(apps: AppEntry[]): void {
|
||||
writeJsonList('apps.json', apps)
|
||||
}
|
||||
|
||||
export function addApp(data: Omit<AppEntry, 'id' | 'source'>): AppEntry {
|
||||
const apps = readApps()
|
||||
const newApp: AppEntry = { ...data, id: uuidv4(), source: 'app' }
|
||||
writeApps([...apps, newApp])
|
||||
return newApp
|
||||
}
|
||||
|
||||
export function removeApp(id: string): void {
|
||||
writeApps(readApps().filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
export function updateApp(updated: AppEntry): void {
|
||||
writeApps(readApps().map((a) => (a.id === updated.id ? { ...a, ...updated } : a)))
|
||||
}
|
||||
|
||||
+85
-63
@@ -1,11 +1,16 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { detectSteamGames } from './steam'
|
||||
import { detectSteamGames, resolveSteamPath } from './steam'
|
||||
import { detectEpicGames } from './epic'
|
||||
import { readCustomGames, writeCustomGames, CustomGame } from './config'
|
||||
import {
|
||||
CustomGame, AppEntry,
|
||||
readCustomGames, addCustomGame, removeCustomGame, updateCustomGame,
|
||||
readApps, addApp, removeApp, updateApp,
|
||||
} from './config'
|
||||
import { launchSteamGame, launchEpicGame, launchExe } from './launcher'
|
||||
import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites'
|
||||
import { readSettings, updateSettings } from './settings'
|
||||
import { scanFolder } from './scanner'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
@@ -34,105 +39,122 @@ function createWindow(): void {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
mainWindow.on('closed', () => { mainWindow = null })
|
||||
}
|
||||
|
||||
// ── IPC Handlers ────────────────────────────────────────────────────────────
|
||||
// ── IPC ──────────────────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('games:get-all', () => {
|
||||
const steamGames = detectSteamGames()
|
||||
const epicGames = detectEpicGames()
|
||||
const { steamPath } = readSettings()
|
||||
const steamGames = detectSteamGames(steamPath)
|
||||
const epicGames = detectEpicGames()
|
||||
const customGames = readCustomGames()
|
||||
return [...steamGames, ...epicGames, ...customGames]
|
||||
const apps = readApps()
|
||||
return [...steamGames, ...epicGames, ...customGames, ...apps]
|
||||
})
|
||||
|
||||
ipcMain.handle('games:launch', (_event, id: string) => {
|
||||
if (id.startsWith('steam_')) {
|
||||
const appid = id.replace('steam_', '')
|
||||
launchSteamGame(appid)
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
launchSteamGame(id.replace('steam_', ''))
|
||||
return { ok: true, recent: addRecent(id) }
|
||||
}
|
||||
|
||||
if (id.startsWith('epic_')) {
|
||||
const appName = id.replace('epic_', '')
|
||||
launchEpicGame(appName)
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
launchEpicGame(id.replace('epic_', ''))
|
||||
return { ok: true, recent: addRecent(id) }
|
||||
}
|
||||
|
||||
const customs = readCustomGames()
|
||||
const game = customs.find((g) => g.id === id)
|
||||
if (!game) return { ok: false, error: 'Game not found' }
|
||||
|
||||
const all = [...readCustomGames(), ...readApps()]
|
||||
const game = all.find((g) => g.id === id)
|
||||
if (!game) return { ok: false, error: 'Not found' }
|
||||
try {
|
||||
launchExe(game.exe, game.args)
|
||||
const recent = addRecent(id)
|
||||
return { ok: true, recent }
|
||||
return { ok: true, recent: addRecent(id) }
|
||||
} 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
|
||||
})
|
||||
// Custom games
|
||||
ipcMain.handle('admin:add-game', (_e, d: Omit<CustomGame, 'id' | 'source'>) => addCustomGame(d))
|
||||
ipcMain.handle('admin:remove-game', (_e, id: string) => { removeCustomGame(id); return { ok: true } })
|
||||
ipcMain.handle('admin:update-game', (_e, g: CustomGame) => { updateCustomGame(g); return { ok: true } })
|
||||
|
||||
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 }
|
||||
})
|
||||
// App entries
|
||||
ipcMain.handle('apps:add', (_e, d: Omit<AppEntry, 'id' | 'source'>) => addApp(d))
|
||||
ipcMain.handle('apps:remove', (_e, id: string) => { removeApp(id); return { ok: true } })
|
||||
ipcMain.handle('apps:update', (_e, a: AppEntry) => { updateApp(a); return { ok: true } })
|
||||
|
||||
// Dialogs
|
||||
ipcMain.handle('dialog:pick-exe', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
const r = await dialog.showOpenDialog({
|
||||
title: 'Выберите исполняемый файл',
|
||||
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd'] }],
|
||||
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd', 'lnk'] }],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
return r.canceled ? null : r.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:pick-image', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
const r = await dialog.showOpenDialog({
|
||||
title: 'Выберите обложку',
|
||||
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
|
||||
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'ico'] }],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
return r.canceled ? null : r.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('favorites:get', () => getFavorites())
|
||||
ipcMain.handle('favorites:toggle', (_event, id: string) => toggleFavorite(id))
|
||||
ipcMain.handle('recent:get', () => getRecent())
|
||||
ipcMain.handle('dialog:pick-folder', async (_e, title = 'Выберите папку') => {
|
||||
const r = await dialog.showOpenDialog({ title, properties: ['openDirectory'] })
|
||||
return r.canceled ? null : r.filePaths[0]
|
||||
})
|
||||
|
||||
// ── App lifecycle ────────────────────────────────────────────────────────────
|
||||
// Settings
|
||||
ipcMain.handle('settings:get', () => {
|
||||
const s = readSettings()
|
||||
return {
|
||||
...s,
|
||||
resolvedSteamPath: resolveSteamPath(s.steamPath),
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:set-steam-path', (_e, path: string | null) => {
|
||||
return updateSettings({ steamPath: path })
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:add-folder', (_e, folder: string) => {
|
||||
const s = readSettings()
|
||||
if (!s.gameFolders.includes(folder)) {
|
||||
return updateSettings({ gameFolders: [...s.gameFolders, folder] })
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:remove-folder', (_e, folder: string) => {
|
||||
const s = readSettings()
|
||||
return updateSettings({ gameFolders: s.gameFolders.filter((f) => f !== folder) })
|
||||
})
|
||||
|
||||
// Scanner
|
||||
ipcMain.handle('scanner:scan', (_e, folder: string) => scanFolder(folder))
|
||||
|
||||
ipcMain.handle('scanner:import', (_e, exes: Array<{ name: string; exe: string }>) => {
|
||||
const imported = exes.map((e) =>
|
||||
addCustomGame({ name: e.name, exe: e.exe, args: [], image: '', category: 'Other' })
|
||||
)
|
||||
return imported
|
||||
})
|
||||
|
||||
// Favorites / recent
|
||||
ipcMain.handle('favorites:get', () => getFavorites())
|
||||
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
|
||||
ipcMain.handle('recent:get', () => getRecent())
|
||||
|
||||
// ── App lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
globalShortcut.register('CommandOrControl+Alt+A', () => {
|
||||
mainWindow?.webContents.send('admin:open')
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
|
||||
+37
-11
@@ -1,24 +1,50 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { CustomGame } from './config'
|
||||
import type { CustomGame, AppEntry } from './config'
|
||||
import type { AppSettings } from './settings'
|
||||
import type { ScannedExe } from './scanner'
|
||||
|
||||
const launcher = {
|
||||
getGames: () => ipcRenderer.invoke('games:get-all'),
|
||||
// Games
|
||||
getGames: () => ipcRenderer.invoke('games:get-all'),
|
||||
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),
|
||||
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise<string | null>,
|
||||
|
||||
// Custom games (admin)
|
||||
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', g),
|
||||
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
|
||||
updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g),
|
||||
|
||||
// App entries (admin)
|
||||
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => ipcRenderer.invoke('apps:add', a),
|
||||
removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id),
|
||||
updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a),
|
||||
|
||||
// Dialogs
|
||||
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise<string | null>,
|
||||
pickFolder: (title?: string) => ipcRenderer.invoke('dialog:pick-folder', title) as Promise<string | null>,
|
||||
|
||||
// Settings
|
||||
getSettings: () => ipcRenderer.invoke('settings:get') as Promise<AppSettings & { resolvedSteamPath: string | null }>,
|
||||
setSteamPath: (path: string | null) => ipcRenderer.invoke('settings:set-steam-path', path) as Promise<AppSettings>,
|
||||
addGameFolder: (folder: string) => ipcRenderer.invoke('settings:add-folder', folder) as Promise<AppSettings>,
|
||||
removeGameFolder: (folder: string) => ipcRenderer.invoke('settings:remove-folder', folder) as Promise<AppSettings>,
|
||||
|
||||
// Scanner
|
||||
scanFolder: (folder: string) => ipcRenderer.invoke('scanner:scan', folder) as Promise<ScannedExe[]>,
|
||||
importScanned: (exes: ScannedExe[]) => ipcRenderer.invoke('scanner:import', exes) as Promise<CustomGame[]>,
|
||||
|
||||
// Favorites / recent
|
||||
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[]>,
|
||||
|
||||
// Events
|
||||
onAdminOpen: (cb: () => void) => {
|
||||
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)
|
||||
|
||||
export type LauncherAPI = typeof launcher
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { join, basename, extname, dirname } from 'path'
|
||||
|
||||
export interface ScannedExe {
|
||||
name: string
|
||||
exe: string
|
||||
}
|
||||
|
||||
// Known non-game executables to skip
|
||||
const SKIP_PATTERNS = [
|
||||
/unins/i, /uninstall/i, /setup/i, /install/i,
|
||||
/update/i, /updater/i, /launcher_helper/i,
|
||||
/crashpad/i, /crashreport/i, /crash_handler/i,
|
||||
/redist/i, /vcredist/i, /dxsetup/i, /ue4prereq/i,
|
||||
/helper/i, /bootstrap/i, /config/i,
|
||||
/cefsharp/i, /steamwebhelper/i,
|
||||
/^vc_redist/i, /^directx/i,
|
||||
]
|
||||
|
||||
function shouldSkip(filename: string): boolean {
|
||||
return SKIP_PATTERNS.some((p) => p.test(filename))
|
||||
}
|
||||
|
||||
function deriveGameName(exePath: string, scanRoot: string): string {
|
||||
const dir = dirname(exePath)
|
||||
const folderName = basename(dir)
|
||||
|
||||
// If exe is directly in scan root, use filename without extension
|
||||
if (dir === scanRoot) {
|
||||
return basename(exePath, extname(exePath))
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
// Otherwise use folder name (usually the game name)
|
||||
return folderName
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a directory 2 levels deep for .exe files.
|
||||
* Returns candidate game executables, sorted by likelihood.
|
||||
*/
|
||||
export function scanFolder(folderPath: string): ScannedExe[] {
|
||||
if (!existsSync(folderPath)) return []
|
||||
|
||||
const results: ScannedExe[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
function scanLevel(dir: string, depth: number): void {
|
||||
let entries: string[]
|
||||
try { entries = readdirSync(dir) } catch { return }
|
||||
|
||||
const exesInDir: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
|
||||
try {
|
||||
const stat = statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory() && depth < 2) {
|
||||
scanLevel(fullPath, depth + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (stat.isFile() && extname(entry).toLowerCase() === '.exe') {
|
||||
if (!shouldSkip(entry)) {
|
||||
exesInDir.push(fullPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip inaccessible
|
||||
}
|
||||
}
|
||||
|
||||
// If a folder has multiple exes, prefer the one matching the folder name
|
||||
if (exesInDir.length > 1) {
|
||||
const folderName = basename(dir).toLowerCase()
|
||||
const preferred = exesInDir.find((p) => basename(p, '.exe').toLowerCase() === folderName)
|
||||
const candidates = preferred ? [preferred] : exesInDir.slice(0, 1)
|
||||
candidates.forEach((p) => {
|
||||
if (!seen.has(p)) { seen.add(p); results.push({ name: deriveGameName(p, folderPath), exe: p }) }
|
||||
})
|
||||
} else {
|
||||
exesInDir.forEach((p) => {
|
||||
if (!seen.has(p)) { seen.add(p); results.push({ name: deriveGameName(p, folderPath), exe: p }) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scanLevel(folderPath, 1)
|
||||
results.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface AppSettings {
|
||||
steamPath: string | null
|
||||
gameFolders: string[]
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
steamPath: null,
|
||||
gameFolders: [],
|
||||
}
|
||||
|
||||
function settingsPath(): string {
|
||||
return join(app.getPath('userData'), 'settings.json')
|
||||
}
|
||||
|
||||
export function readSettings(): AppSettings {
|
||||
const path = settingsPath()
|
||||
if (!existsSync(path)) return { ...DEFAULTS }
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
return {
|
||||
steamPath: typeof raw.steamPath === 'string' ? raw.steamPath : null,
|
||||
gameFolders: Array.isArray(raw.gameFolders) ? raw.gameFolders.filter((f: unknown) => typeof f === 'string') : [],
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSettings(settings: AppSettings): void {
|
||||
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
|
||||
const current = readSettings()
|
||||
const updated = { ...current, ...patch }
|
||||
writeSettings(updated)
|
||||
return updated
|
||||
}
|
||||
+11
-2
@@ -163,8 +163,11 @@ function scanSteamApps(steamappsDir: string): SteamGame[] {
|
||||
return games
|
||||
}
|
||||
|
||||
export function detectSteamGames(): SteamGame[] {
|
||||
const steamPath = getSteamPathFromRegistry()
|
||||
export function detectSteamGames(customSteamPath?: string | null): SteamGame[] {
|
||||
const steamPath = customSteamPath && existsSync(customSteamPath)
|
||||
? customSteamPath
|
||||
: getSteamPathFromRegistry()
|
||||
|
||||
if (!steamPath) return []
|
||||
|
||||
const libraryFolders = getLibraryFolders(steamPath)
|
||||
@@ -177,3 +180,9 @@ export function detectSteamGames(): SteamGame[] {
|
||||
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||
return games
|
||||
}
|
||||
|
||||
/** Returns the currently resolved Steam path (for display in settings UI) */
|
||||
export function resolveSteamPath(customPath?: string | null): string | null {
|
||||
if (customPath && existsSync(customPath)) return customPath
|
||||
return getSteamPathFromRegistry()
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "club-launcher",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "club-launcher",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@node-steam/vdf": "^2.0.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "club-launcher",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"description": "Game launcher for computer club",
|
||||
"author": "houseassassin",
|
||||
"main": "out/main/main.js",
|
||||
|
||||
+84
-52
@@ -1,21 +1,32 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA } from 'lucide-react'
|
||||
import { Gamepad2, Settings, RefreshCw, ArrowDownAZ, ArrowUpZA, SlidersHorizontal } 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, SortOrder } from './types'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import type { AppEntry, 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 [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 [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [favorites, setFavorites] = useState<string[]>([])
|
||||
const [recent, setRecent] = useState<string[]>([])
|
||||
const [recent, setRecent] = useState<string[]>([])
|
||||
|
||||
// Load initial theme from localStorage
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('theme') === 'light') {
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.launcher.getFavorites().then(setFavorites)
|
||||
@@ -23,22 +34,22 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.launcher.onAdminOpen(() => {
|
||||
return window.launcher.onAdminOpen(() => {
|
||||
setAdminOpen(true)
|
||||
setAdminMode(true)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
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,
|
||||
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,
|
||||
app: games.filter((g) => g.source === 'app').length,
|
||||
favorites: games.filter((g) => favorites.includes(g.id)).length,
|
||||
recent: games.filter((g) => recentSet.has(g.id)).length,
|
||||
recent: games.filter((g) => recentSet.has(g.id)).length,
|
||||
}
|
||||
}, [games, favorites, recent])
|
||||
|
||||
@@ -49,9 +60,9 @@ export default function App() {
|
||||
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)
|
||||
list = games
|
||||
.filter((g) => recentMap.has(g.id))
|
||||
.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999))
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter((g) => g.name.toLowerCase().includes(q))
|
||||
@@ -73,7 +84,6 @@ export default function App() {
|
||||
? a.name.localeCompare(b.name, 'ru')
|
||||
: b.name.localeCompare(a.name, 'ru'),
|
||||
)
|
||||
|
||||
return list
|
||||
}, [games, category, search, sort, favorites, recent])
|
||||
|
||||
@@ -83,40 +93,41 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
const handleToggleFavorite = useCallback(async (id: string) => {
|
||||
const updated = await window.launcher.toggleFavorite(id)
|
||||
setFavorites(updated)
|
||||
setFavorites(await window.launcher.toggleFavorite(id))
|
||||
}, [])
|
||||
|
||||
const handleAdd = useCallback(async (payload: Omit<CustomGame, 'id' | 'source'>) => {
|
||||
await window.launcher.addCustomGame(payload)
|
||||
await reload()
|
||||
}, [reload])
|
||||
// Custom games
|
||||
const handleAdd = useCallback(async (g: Omit<CustomGame, 'id' | 'source'>) => { await window.launcher.addCustomGame(g); await reload() }, [reload])
|
||||
const handleRemove = useCallback(async (id: string) => { await window.launcher.removeGame(id); await reload() }, [reload])
|
||||
const handleUpdate = useCallback(async (g: CustomGame) => { await window.launcher.updateGame(g); await reload() }, [reload])
|
||||
|
||||
const handleRemove = useCallback(async (id: string) => {
|
||||
await window.launcher.removeGame(id)
|
||||
await reload()
|
||||
}, [reload])
|
||||
// App entries
|
||||
const handleAddApp = useCallback(async (a: Omit<AppEntry, 'id' | 'source'>) => { await window.launcher.addApp(a); await reload() }, [reload])
|
||||
const handleRemoveApp = useCallback(async (id: string) => { await window.launcher.removeApp(id); await reload() }, [reload])
|
||||
const handleUpdateApp = useCallback(async (a: AppEntry) => { await window.launcher.updateApp(a); await reload() }, [reload])
|
||||
|
||||
const handleUpdate = useCallback(async (game: CustomGame) => {
|
||||
await window.launcher.updateGame(game)
|
||||
await reload()
|
||||
}, [reload])
|
||||
const closeAdmin = () => { setAdminOpen(false); setAdminMode(false) }
|
||||
|
||||
const closeAdmin = () => {
|
||||
setAdminOpen(false)
|
||||
setAdminMode(false)
|
||||
}
|
||||
|
||||
const hasEpic = counts.epic > 0
|
||||
const hasEpic = counts.epic > 0
|
||||
const hasFavorites = favorites.length > 0
|
||||
const hasRecent = recent.length > 0
|
||||
const hasRecent = recent.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text overflow-hidden">
|
||||
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--bg)', color: 'var(--text)' }}>
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 px-6 py-3 bg-card border-b border-border shrink-0">
|
||||
<header
|
||||
className="flex items-center gap-3 px-6 py-3 border-b shrink-0"
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderColor: 'var(--border)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-1">
|
||||
<Gamepad2 size={22} className="text-accent" />
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent to-accentHover flex items-center justify-center shadow-md shadow-accent/30">
|
||||
<Gamepad2 size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-base tracking-wide">Club Launcher</span>
|
||||
</div>
|
||||
|
||||
@@ -133,28 +144,37 @@ export default function App() {
|
||||
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
|
||||
{/* 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' ? 'Сортировка А→Я' : 'Сортировка Я→А'}
|
||||
title={sort === 'name-asc' ? 'А→Я' : 'Я→А'}
|
||||
>
|
||||
{sort === 'name-asc' ? <ArrowDownAZ size={17} /> : <ArrowUpZA size={17} />}
|
||||
</button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted hover:text-text transition-colors disabled:opacity-40"
|
||||
title="Обновить библиотеку"
|
||||
title="Обновить"
|
||||
>
|
||||
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="p-2 text-muted hover:text-text transition-colors"
|
||||
title="Настройки"
|
||||
>
|
||||
<SlidersHorizontal size={17} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setAdminOpen(true); setAdminMode(true) }}
|
||||
className="p-2 text-muted hover:text-accent transition-colors"
|
||||
title="Управление играми"
|
||||
title="Управление играми (Ctrl+Alt+A)"
|
||||
>
|
||||
<Settings size={17} />
|
||||
</button>
|
||||
@@ -168,15 +188,24 @@ export default function App() {
|
||||
onEdit={() => { if (!adminOpen) setAdminOpen(true) }}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
adminMode={adminMode}
|
||||
category={category}
|
||||
/>
|
||||
|
||||
<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
|
||||
className="px-6 py-2 border-t shrink-0 text-xs"
|
||||
style={{ background: 'var(--card)', borderColor: 'var(--border)', color: 'var(--muted)' }}
|
||||
>
|
||||
{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} записей`}
|
||||
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
||||
</footer>
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onImported={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adminOpen && (
|
||||
<AdminPanel
|
||||
games={games}
|
||||
@@ -184,6 +213,9 @@ export default function App() {
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
onUpdate={handleUpdate}
|
||||
onAddApp={handleAddApp}
|
||||
onRemoveApp={handleRemoveApp}
|
||||
onUpdateApp={handleUpdateApp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+179
-136
@@ -1,16 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Plus, Trash2, Edit2, FolderOpen, Image } from 'lucide-react'
|
||||
import type { CustomGame, Game, GameFormData } from '../types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Plus, Trash2, Edit2, FolderOpen, Image, Gamepad2, AppWindow } from 'lucide-react'
|
||||
import type { AppEntry, CustomGame, Game, GameFormData } from '../types'
|
||||
|
||||
const CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
|
||||
const GAME_CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
|
||||
const APP_CATEGORIES = ['Браузер', 'Мессенджер', 'Инструменты', 'Медиа', 'Другое']
|
||||
|
||||
const EMPTY_FORM: GameFormData = {
|
||||
name: '',
|
||||
exe: '',
|
||||
args: '',
|
||||
image: '',
|
||||
category: 'Other',
|
||||
}
|
||||
const EMPTY_FORM: GameFormData = { name: '', exe: '', args: '', image: '', category: 'Other' }
|
||||
|
||||
type EntryKind = 'game' | 'app'
|
||||
type Mode = 'list' | 'add' | 'edit'
|
||||
|
||||
interface Props {
|
||||
games: Game[]
|
||||
@@ -18,68 +16,89 @@ interface Props {
|
||||
onAdd: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<void>
|
||||
onRemove: (id: string) => Promise<void>
|
||||
onUpdate: (game: CustomGame) => Promise<void>
|
||||
onAddApp: (app: Omit<AppEntry, 'id' | 'source'>) => Promise<void>
|
||||
onRemoveApp: (id: string) => Promise<void>
|
||||
onUpdateApp: (app: AppEntry) => Promise<void>
|
||||
}
|
||||
|
||||
type Mode = 'list' | 'add' | 'edit'
|
||||
export function AdminPanel({
|
||||
games, onClose,
|
||||
onAdd, onRemove, onUpdate,
|
||||
onAddApp, onRemoveApp, onUpdateApp,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<Mode>('list')
|
||||
const [kind, setKind] = useState<EntryKind>('game')
|
||||
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 [visible, setVisible] = useState(false)
|
||||
|
||||
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)
|
||||
// Animate in
|
||||
useEffect(() => { requestAnimationFrame(() => setVisible(true)) }, [])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
setTimeout(onClose, 280)
|
||||
}
|
||||
|
||||
const customGames = games.filter((g): g is CustomGame => g.source === 'custom')
|
||||
const appEntries = games.filter((g): g is AppEntry => g.source === 'app')
|
||||
|
||||
const startAdd = () => {
|
||||
setForm(EMPTY_FORM)
|
||||
const startAdd = (k: EntryKind) => {
|
||||
const defaultCat = k === 'game' ? 'Other' : 'Другое'
|
||||
setForm({ ...EMPTY_FORM, category: defaultCat })
|
||||
setKind(k)
|
||||
setEditId(null)
|
||||
setError(null)
|
||||
setMode('add')
|
||||
}
|
||||
|
||||
const startEdit = (game: CustomGame) => {
|
||||
const startEdit = (game: CustomGame | AppEntry) => {
|
||||
setForm({
|
||||
name: game.name,
|
||||
exe: game.exe,
|
||||
args: game.args.join(' '),
|
||||
image: game.image,
|
||||
name: game.name,
|
||||
exe: game.exe,
|
||||
args: game.args.join(' '),
|
||||
image: game.image,
|
||||
category: game.category,
|
||||
})
|
||||
setKind(game.source === 'app' ? 'app' : 'game')
|
||||
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 pickExe = async () => { const p = await window.launcher.pickExe(); if (p) setForm((f) => ({ ...f, exe: p })) }
|
||||
const pickImage = async () => { const p = await window.launcher.pickImage(); if (p) setForm((f) => ({ ...f, image: p })) }
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { setError('Введите название'); return }
|
||||
if (!form.exe.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(),
|
||||
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' })
|
||||
if (kind === 'game') {
|
||||
if (mode === 'edit' && editId) await onUpdate({ ...payload, id: editId, source: 'custom' })
|
||||
else await onAdd(payload)
|
||||
} else {
|
||||
await onAdd(payload)
|
||||
if (mode === 'edit' && editId) await onUpdateApp({ ...payload, id: editId, source: 'app' })
|
||||
else await onAddApp(payload)
|
||||
}
|
||||
setMode('list')
|
||||
} catch (e) {
|
||||
@@ -89,92 +108,129 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string, name: string) => {
|
||||
const handleRemove = async (id: string, name: string, isApp: boolean) => {
|
||||
if (!confirm(`Удалить «${name}»?`)) return
|
||||
await onRemove(id)
|
||||
if (isApp) await onRemoveApp(id)
|
||||
else await onRemove(id)
|
||||
}
|
||||
|
||||
const categories = kind === 'game' ? GAME_CATEGORIES : APP_CATEGORIES
|
||||
|
||||
const itemTitle = kind === 'game' ? 'игру' : 'приложение'
|
||||
|
||||
const EntryRow = ({ entry, isApp }: { entry: CustomGame | AppEntry; isApp: boolean }) => (
|
||||
<div className="flex items-center gap-3 p-3 bg-bg rounded-lg border border-border">
|
||||
{entry.image ? (
|
||||
<img
|
||||
src={entry.image.startsWith('http') ? entry.image : `file://${entry.image.replace(/\\/g, '/')}`}
|
||||
alt={entry.name}
|
||||
className="w-12 h-7 object-cover rounded shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-7 bg-border rounded shrink-0 flex items-center justify-center">
|
||||
{isApp ? <AppWindow size={12} className="text-muted" /> : <Gamepad2 size={12} className="text-muted" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-text text-sm font-medium truncate">{entry.name}</p>
|
||||
<p className="text-muted text-xs truncate">{entry.exe}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted/70 px-2 py-0.5 bg-border rounded-full shrink-0">
|
||||
{entry.category}
|
||||
</span>
|
||||
<button onClick={() => startEdit(entry)} className="text-muted hover:text-accent transition-colors p-1">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleRemove(entry.id, entry.name, isApp)} className="text-muted hover:text-red-400 transition-colors p-1">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
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">
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex justify-end transition-all duration-280 ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
|
||||
style={{ background: visible ? 'rgba(0,0,0,0.7)' : 'transparent', backdropFilter: visible ? 'blur(4px)' : 'none', transition: 'all 0.28s ease' }}
|
||||
>
|
||||
<div
|
||||
className={`relative flex flex-col w-full max-w-xl h-full bg-card border-l border-border shadow-2xl transition-transform duration-280 ease-spring ${visible ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/80 backdrop-blur-xs shrink-0">
|
||||
<div>
|
||||
<h2 className="text-text font-semibold text-lg">
|
||||
{mode === 'list' ? 'Управление играми' : mode === 'add' ? 'Добавить игру' : 'Редактировать игру'}
|
||||
{mode === 'list' ? 'Управление' : mode === 'add' ? `Добавить ${itemTitle}` : `Редактировать ${itemTitle}`}
|
||||
</h2>
|
||||
<p className="text-muted text-xs mt-0.5">Admin Panel · Ctrl+Alt+A</p>
|
||||
<p className="text-muted text-xs mt-0.5">Admin · Ctrl+Alt+A · Esc — закрыть</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted hover:text-text transition-colors p-1">
|
||||
<X size={20} />
|
||||
<button onClick={handleClose} className="p-2 text-muted hover:text-text transition-colors rounded-lg hover:bg-cardHover">
|
||||
<X size={18} />
|
||||
</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"
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Custom games section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text text-sm font-semibold flex items-center gap-2">
|
||||
<Gamepad2 size={14} className="text-accent" /> Игры ({customGames.length})
|
||||
</h3>
|
||||
<button onClick={() => startAdd('game')} className="flex items-center gap-1.5 px-3 py-1.5 bg-accent hover:bg-accentHover text-white rounded-lg text-xs font-medium transition-colors">
|
||||
<Plus size={13} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
{customGames.length === 0
|
||||
? <p className="text-muted text-xs text-center py-4 border border-dashed border-border rounded-lg">Нет добавленных игр</p>
|
||||
: <div className="space-y-2">{customGames.map((g) => <EntryRow key={g.id} entry={g} isApp={false} />)}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Apps section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text text-sm font-semibold flex items-center gap-2">
|
||||
<AppWindow size={14} className="text-blue-400" /> Приложения ({appEntries.length})
|
||||
</h3>
|
||||
<button onClick={() => startAdd('app')} className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-xs font-medium transition-colors">
|
||||
<Plus size={13} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
{appEntries.length === 0
|
||||
? <p className="text-muted text-xs text-center py-4 border border-dashed border-border rounded-lg">Нет добавленных приложений</p>
|
||||
: <div className="space-y-2">{appEntries.map((a) => <EntryRow key={a.id} entry={a} isApp={true} />)}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-muted text-xs">
|
||||
Steam ({games.filter((g) => g.source === 'steam').length}) и Epic ({games.filter((g) => g.source === 'epic').length}) определяются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Form */
|
||||
<div className="p-6 space-y-4 animate-slide-in-up">
|
||||
{/* Kind toggle (only when adding) */}
|
||||
{mode === 'add' && (
|
||||
<div className="flex rounded-lg overflow-hidden border border-border">
|
||||
{(['game', 'app'] as EntryKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => { setKind(k); setForm((f) => ({ ...f, category: k === 'game' ? 'Other' : 'Другое' })) }}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium transition-colors ${
|
||||
kind === k ? 'bg-accent text-white' : 'text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
{k === 'game' ? <><Gamepad2 size={14} /> Игра</> : <><AppWindow size={14} /> Приложение</>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-detected info */}
|
||||
<p className="text-muted text-xs mb-4">
|
||||
Steam ({games.filter((g) => g.source === 'steam').length}) и Epic (
|
||||
{games.filter((g) => g.source === 'epic').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>
|
||||
@@ -182,7 +238,7 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Counter-Strike 2"
|
||||
placeholder={kind === 'game' ? 'Counter-Strike 2' : 'Discord'}
|
||||
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>
|
||||
@@ -195,22 +251,18 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
type="text"
|
||||
value={form.exe}
|
||||
onChange={(e) => setForm((f) => ({ ...f, exe: e.target.value }))}
|
||||
placeholder="C:\Games\game.exe"
|
||||
placeholder="C:\Program Files\App\app.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 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={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
<div>
|
||||
<label className="text-text text-sm mb-1 block">Аргументы запуска <span className="text-muted">(необязательно)</span></label>
|
||||
<label className="text-text text-sm mb-1 block">Аргументы <span className="text-muted">(необязательно)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.args}
|
||||
@@ -222,28 +274,24 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
|
||||
{/* Image */}
|
||||
<div>
|
||||
<label className="text-text text-sm mb-1 block">Обложка <span className="text-muted">(необязательно)</span></label>
|
||||
<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://..."
|
||||
placeholder="C:\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 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={15} />
|
||||
</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"
|
||||
className="mt-2 h-14 rounded object-cover border border-border"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
@@ -257,9 +305,7 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
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>
|
||||
))}
|
||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -268,19 +314,16 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer for form modes */}
|
||||
{/* Footer */}
|
||||
{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"
|
||||
>
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
||||
<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"
|
||||
className="px-5 py-2 bg-accent hover:bg-accentHover disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Сохранение...' : mode === 'add' ? 'Добавить' : 'Сохранить'}
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import type { Category } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -11,28 +12,52 @@ interface Props {
|
||||
|
||||
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: 'Добавленные' },
|
||||
{ key: 'all', label: 'Все' },
|
||||
{ key: 'steam', label: 'Steam' },
|
||||
...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []),
|
||||
{ key: 'custom', label: 'Игры' },
|
||||
{ key: 'app', label: 'Приложения' },
|
||||
...(hasFavorites ? [{ key: 'favorites' as Category, label: '⭐ Избранное' }] : []),
|
||||
...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []),
|
||||
...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []),
|
||||
]
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [indicator, setIndicator] = useState({ left: 0, width: 0 })
|
||||
const buttonRefs = useRef<Map<Category, HTMLButtonElement>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const el = buttonRefs.current.get(active)
|
||||
const container = containerRef.current
|
||||
if (!el || !container) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
setIndicator({
|
||||
left: elRect.left - containerRect.left,
|
||||
width: elRect.width,
|
||||
})
|
||||
}, [active, tabs.length])
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div ref={containerRef} className="relative flex gap-0.5">
|
||||
{/* Sliding indicator */}
|
||||
<span
|
||||
className="absolute bottom-0 h-0.5 bg-accent rounded-full transition-all duration-200 ease-spring"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
ref={(el) => { if (el) buttonRefs.current.set(key, el) }}
|
||||
onClick={() => onChange(key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 pb-2 rounded-t-lg text-sm font-medium transition-colors ${
|
||||
active === key
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-card text-muted hover:text-text hover:bg-cardHover'
|
||||
? 'text-text'
|
||||
: 'text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`ml-1.5 text-xs ${active === key ? 'text-white/70' : 'text-muted/70'}`}>
|
||||
<span className={`ml-1.5 text-xs ${active === key ? 'text-accent' : 'text-muted/60'}`}>
|
||||
{counts[key]}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
+116
-48
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Play, Settings, Star } from 'lucide-react'
|
||||
import type { Game } from '../types'
|
||||
|
||||
const COLORS = [
|
||||
// ── Placeholder ───────────────────────────────────────────────────────────
|
||||
|
||||
const GRADIENTS = [
|
||||
['#1a1a3e', '#2d1b69'],
|
||||
['#1e3a5f', '#0d2137'],
|
||||
['#2d1b2e', '#4a1942'],
|
||||
@@ -15,13 +17,12 @@ function nameHash(name: string): number {
|
||||
}
|
||||
|
||||
function makePlaceholder(name: string): string {
|
||||
const [c1, c2] = COLORS[nameHash(name) % COLORS.length]
|
||||
const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length]
|
||||
const initial = (name[0] ?? '?').toUpperCase()
|
||||
const safe = name.replace(/</g, '<').replace(/>/g, '>')
|
||||
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}"/>
|
||||
<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>
|
||||
@@ -30,22 +31,44 @@ function makePlaceholder(name: string): string {
|
||||
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) {
|
||||
if (game.image.startsWith('http')) return game.image
|
||||
return `file://${game.image.replace(/\\/g, '/')}`
|
||||
}
|
||||
return makePlaceholder(game.name)
|
||||
function makeAppPlaceholder(name: string): string {
|
||||
const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length]
|
||||
const initial = (name[0] ?? '?').toUpperCase()
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||||
<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="200" height="200" rx="24" fill="url(#g)"/>
|
||||
<text x="100" y="128" font-size="90" fill="white" fill-opacity="0.9" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
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' },
|
||||
function getImageSrc(game: Game): string {
|
||||
if (game.source === 'steam') return game.headerUrl
|
||||
if (game.source === 'epic') return makePlaceholder(game.name)
|
||||
if (game.source === 'app') {
|
||||
if (!game.image) return makeAppPlaceholder(game.name)
|
||||
if (game.image.startsWith('http')) return game.image
|
||||
return `file://${game.image.replace(/\\/g, '/')}`
|
||||
}
|
||||
// custom
|
||||
if (!game.image) return makePlaceholder(game.name)
|
||||
if (game.image.startsWith('http')) return game.image
|
||||
return `file://${game.image.replace(/\\/g, '/')}`
|
||||
}
|
||||
|
||||
// ── Badge config ──────────────────────────────────────────────────────────
|
||||
|
||||
const 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: 'GAME' },
|
||||
app: { bg: 'bg-blue-600/30', text: 'text-blue-300', label: 'APP' },
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
game: Game
|
||||
index: number
|
||||
@@ -54,81 +77,126 @@ interface Props {
|
||||
onToggleFavorite?: (id: string) => void
|
||||
isFavorite?: boolean
|
||||
adminMode?: boolean
|
||||
isApp?: boolean // renders in square app-tile mode
|
||||
}
|
||||
|
||||
export function GameCard({ game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function GameCard({
|
||||
game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode, isApp,
|
||||
}: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
const [favBounce, setFavBounce] = useState(false)
|
||||
const [tilt, setTilt] = useState({ x: 0, y: 0 })
|
||||
const [ripple, setRipple] = useState(false)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 3D tilt on mouse move
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const rect = cardRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const x = (e.clientX - rect.left) / rect.width - 0.5
|
||||
const y = (e.clientY - rect.top) / rect.height - 0.5
|
||||
setTilt({ x: y * 6, y: -x * 6 })
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = () => setTilt({ x: 0, y: 0 })
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setRipple(true)
|
||||
setTimeout(() => setRipple(false), 500)
|
||||
setLaunching(true)
|
||||
try {
|
||||
await onLaunch(game.id)
|
||||
} finally {
|
||||
setTimeout(() => setLaunching(false), 2000)
|
||||
}
|
||||
try { await onLaunch(game.id) }
|
||||
finally { setTimeout(() => setLaunching(false), 2000) }
|
||||
}
|
||||
|
||||
const badge = SOURCE_BADGE[game.source]
|
||||
const handleFavorite = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setFavBounce(true)
|
||||
setTimeout(() => setFavBounce(false), 350)
|
||||
onToggleFavorite?.(game.id)
|
||||
}
|
||||
|
||||
const badge = BADGE[game.source] ?? BADGE.custom
|
||||
const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game)
|
||||
const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]'
|
||||
const playLabel = isApp ? 'Открыть' : 'Играть'
|
||||
|
||||
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 animate-fade-in"
|
||||
style={{ animationDelay: `${Math.min(index * 25, 400)}ms` }}
|
||||
ref={cardRef}
|
||||
className="group relative rounded-xl overflow-hidden bg-card cursor-pointer select-none animate-fade-in"
|
||||
style={{
|
||||
animationDelay: `${Math.min(index * 22, 350)}ms`,
|
||||
transform: `perspective(800px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg) scale(${tilt.x || tilt.y ? 1.03 : 1})`,
|
||||
transition: 'transform 0.15s ease-out, box-shadow 0.2s ease-out',
|
||||
boxShadow: (tilt.x || tilt.y)
|
||||
? '0 16px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--accent)20'
|
||||
: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div className="aspect-[460/215] w-full overflow-hidden">
|
||||
{/* Cover */}
|
||||
<div className={`${aspect} w-full overflow-hidden`}>
|
||||
<img
|
||||
src={imgError ? makePlaceholder(game.name) : getImageSrc(game)}
|
||||
src={imgSrc}
|
||||
alt={game.name}
|
||||
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-75"
|
||||
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-70"
|
||||
onError={() => setImgError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source badge */}
|
||||
<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 */}
|
||||
<span className={`absolute top-2 left-2 px-2 py-0.5 text-[10px] font-semibold rounded-full select-none ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
|
||||
{/* Favorite button */}
|
||||
{/* Favorite */}
|
||||
{!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 ${
|
||||
onClick={handleFavorite}
|
||||
className={`absolute top-2 right-2 p-1.5 rounded-full bg-black/60 transition-all duration-150 ${
|
||||
isFavorite
|
||||
? 'text-yellow-400 opacity-100'
|
||||
: 'text-white/50 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
: 'text-white/40 opacity-0 group-hover:opacity-100'
|
||||
} ${favBounce ? 'animate-bounce-star' : ''}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
<Star size={13} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
<Star size={12} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Admin edit button */}
|
||||
{/* Admin edit */}
|
||||
{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"
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-muted hover:text-text transition-colors"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<Settings size={13} />
|
||||
</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">
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gradient-to-t from-black/80 via-black/20 to-transparent">
|
||||
<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"
|
||||
className="relative overflow-hidden flex items-center justify-center gap-2 w-full py-2 rounded-lg bg-gradient-to-r from-accent to-accentHover hover:brightness-110 disabled:opacity-50 text-white font-semibold text-sm transition-all"
|
||||
>
|
||||
<Play size={14} fill="white" />
|
||||
{launching ? 'Запуск...' : 'Играть'}
|
||||
{/* Ripple */}
|
||||
{ripple && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="w-4 h-4 rounded-full bg-white/30 animate-ripple" />
|
||||
</span>
|
||||
)}
|
||||
<Play size={13} fill="white" />
|
||||
{launching ? 'Запуск...' : playLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { Gamepad2 } from 'lucide-react'
|
||||
import { GameCard } from './GameCard'
|
||||
import type { Game } from '../types'
|
||||
@@ -10,23 +11,39 @@ interface Props {
|
||||
onEdit: (game: Game) => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
adminMode: boolean
|
||||
category: string
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
function SkeletonCard({ square }: { square?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden bg-card">
|
||||
<div className="aspect-[460/215] w-full bg-cardHover animate-shimmer" />
|
||||
<div className="rounded-xl overflow-hidden bg-card">
|
||||
<div className={`w-full bg-cardHover animate-shimmer ${square ? 'aspect-square' : 'aspect-[460/215]'}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, adminMode }: Props) {
|
||||
export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, adminMode, category }: Props) {
|
||||
const isApps = category === 'app'
|
||||
const gridCols = isApps
|
||||
? 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8'
|
||||
: 'grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||
|
||||
// Key changes when category switches to re-trigger card animations
|
||||
const [renderKey, setRenderKey] = useState(0)
|
||||
const prevCat = useRef(category)
|
||||
useEffect(() => {
|
||||
if (prevCat.current !== category) {
|
||||
prevCat.current = category
|
||||
setRenderKey((k) => k + 1)
|
||||
}
|
||||
}, [category])
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<div className={`grid ${gridCols} gap-3`}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
<SkeletonCard key={i} square={isApps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,8 +55,12 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
||||
<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/Epic установлены или добавьте игры вручную</p>
|
||||
<p className="text-base">Ничего не найдено</p>
|
||||
<p className="text-sm text-muted/60">
|
||||
{category === 'app'
|
||||
? 'Добавьте приложения через панель администратора'
|
||||
: 'Убедитесь что Steam/Epic установлены или добавьте игры вручную'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -47,7 +68,7 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
||||
|
||||
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">
|
||||
<div key={renderKey} className={`grid ${gridCols} gap-3`}>
|
||||
{games.map((game, i) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
@@ -58,6 +79,7 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
isFavorite={favorites.includes(game.id)}
|
||||
adminMode={adminMode}
|
||||
isApp={isApps || game.source === 'app'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, FolderOpen, SteamIcon, Folder, Trash2, ScanSearch, CheckSquare, Square, Plus, RefreshCw, Check } from 'lucide-react'
|
||||
|
||||
interface ScannedExe {
|
||||
name: string
|
||||
exe: string
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
steamPath: string | null
|
||||
gameFolders: string[]
|
||||
resolvedSteamPath: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
onImported: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({ onClose, onImported }: Props) {
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [savingSteam, setSavingSteam] = useState(false)
|
||||
|
||||
// Scanner state
|
||||
const [scanning, setScanning] = useState<string | null>(null) // folder being scanned
|
||||
const [scanResults, setScanResults] = useState<Record<string, ScannedExe[]>>({})
|
||||
const [selected, setSelected] = useState<Record<string, Set<string>>>({})
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [imported, setImported] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true))
|
||||
window.launcher.getSettings().then(setSettings)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
setTimeout(onClose, 280)
|
||||
}
|
||||
|
||||
// ── Steam path ─────────────────────────────────────────────────────────
|
||||
|
||||
const pickSteamPath = async () => {
|
||||
const path = await window.launcher.pickFolder('Укажите папку установки Steam')
|
||||
if (!path || !settings) return
|
||||
setSavingSteam(true)
|
||||
const updated = await window.launcher.setSteamPath(path)
|
||||
setSettings({ ...updated, resolvedSteamPath: path })
|
||||
setSavingSteam(false)
|
||||
}
|
||||
|
||||
const clearSteamPath = async () => {
|
||||
if (!settings) return
|
||||
const updated = await window.launcher.setSteamPath(null)
|
||||
setSettings((s) => s ? { ...updated, resolvedSteamPath: s.resolvedSteamPath } : s)
|
||||
}
|
||||
|
||||
// ── Game folders ───────────────────────────────────────────────────────
|
||||
|
||||
const addFolder = async () => {
|
||||
const folder = await window.launcher.pickFolder('Выберите папку с играми')
|
||||
if (!folder) return
|
||||
const updated = await window.launcher.addGameFolder(folder)
|
||||
setSettings((s) => s ? { ...s, gameFolders: updated.gameFolders } : s)
|
||||
}
|
||||
|
||||
const removeFolder = async (folder: string) => {
|
||||
const updated = await window.launcher.removeGameFolder(folder)
|
||||
setSettings((s) => s ? { ...s, gameFolders: updated.gameFolders } : s)
|
||||
setScanResults((r) => { const n = { ...r }; delete n[folder]; return n })
|
||||
setSelected((r) => { const n = { ...r }; delete n[folder]; return n })
|
||||
}
|
||||
|
||||
const scanFolder = async (folder: string) => {
|
||||
setScanning(folder)
|
||||
try {
|
||||
const exes = await window.launcher.scanFolder(folder)
|
||||
setScanResults((r) => ({ ...r, [folder]: exes }))
|
||||
// Pre-select all by default
|
||||
setSelected((r) => ({
|
||||
...r,
|
||||
[folder]: new Set(exes.map((e) => e.exe)),
|
||||
}))
|
||||
} finally {
|
||||
setScanning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExe = (folder: string, exe: string) => {
|
||||
setSelected((r) => {
|
||||
const set = new Set(r[folder] ?? [])
|
||||
if (set.has(exe)) set.delete(exe); else set.add(exe)
|
||||
return { ...r, [folder]: set }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAll = (folder: string) => {
|
||||
const results = scanResults[folder] ?? []
|
||||
const sel = selected[folder] ?? new Set()
|
||||
const allSelected = results.every((e) => sel.has(e.exe))
|
||||
setSelected((r) => ({
|
||||
...r,
|
||||
[folder]: allSelected ? new Set() : new Set(results.map((e) => e.exe)),
|
||||
}))
|
||||
}
|
||||
|
||||
const importSelected = async (folder: string) => {
|
||||
const results = scanResults[folder] ?? []
|
||||
const sel = selected[folder] ?? new Set()
|
||||
const toImport = results.filter((e) => sel.has(e.exe))
|
||||
if (toImport.length === 0) return
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
await window.launcher.importScanned(toImport)
|
||||
setImported((i) => ({ ...i, [folder]: toImport.length }))
|
||||
onImported()
|
||||
// Clear scan results for this folder after import
|
||||
setTimeout(() => {
|
||||
setScanResults((r) => { const n = { ...r }; delete n[folder]; return n })
|
||||
setSelected((r) => { const n = { ...r }; delete n[folder]; return n })
|
||||
setImported((r) => { const n = { ...r }; delete n[folder]; return n })
|
||||
}, 2000)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex justify-end transition-all duration-280 ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
|
||||
style={{ background: visible ? 'rgba(0,0,0,0.7)' : 'transparent', backdropFilter: visible ? 'blur(4px)' : 'none', transition: 'all 0.28s ease' }}
|
||||
>
|
||||
<div
|
||||
className={`relative flex flex-col w-full max-w-xl h-full shadow-2xl transition-transform duration-280 ease-spring border-l ${visible ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b shrink-0" style={{ borderColor: 'var(--border)' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg" style={{ color: 'var(--text)' }}>Настройки</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>Пути Steam и папки с играми</p>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-2 rounded-lg transition-colors hover:bg-cardHover" style={{ color: 'var(--muted)' }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-8">
|
||||
|
||||
{/* ── Steam section ─────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2" style={{ color: 'var(--text)' }}>
|
||||
<span className="w-5 h-5 rounded bg-[#1b2838] flex items-center justify-center text-[10px] text-[#c7d5e0] font-bold">S</span>
|
||||
Steam
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
<p>
|
||||
<span className="font-medium" style={{ color: 'var(--text)' }}>Автоопределённый путь: </span>
|
||||
{settings.resolvedSteamPath ?? <span className="text-red-400">не найден</span>}
|
||||
</p>
|
||||
|
||||
{settings.steamPath && (
|
||||
<p>
|
||||
<span className="font-medium" style={{ color: 'var(--text)' }}>Пользовательский путь: </span>
|
||||
<span className="font-mono text-xs break-all">{settings.steamPath}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={pickSteamPath}
|
||||
disabled={savingSteam}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent hover:bg-accentHover text-white disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{settings.steamPath ? 'Изменить путь' : 'Указать вручную'}
|
||||
</button>
|
||||
|
||||
{settings.steamPath && (
|
||||
<button
|
||||
onClick={clearSteamPath}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-cardHover"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Game folders section ───────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2" style={{ color: 'var(--text)' }}>
|
||||
<Folder size={16} className="text-accent" />
|
||||
Папки с играми
|
||||
</h3>
|
||||
|
||||
<p className="text-xs mb-4" style={{ color: 'var(--muted)' }}>
|
||||
Добавьте папки (например D:\Games) — лаунчер просканирует их и предложит импортировать найденные игры.
|
||||
</p>
|
||||
|
||||
{/* Folder list */}
|
||||
<div className="space-y-4">
|
||||
{settings.gameFolders.map((folder) => {
|
||||
const results = scanResults[folder]
|
||||
const sel = selected[folder] ?? new Set<string>()
|
||||
const isScanning = scanning === folder
|
||||
const importedCount = imported[folder]
|
||||
|
||||
return (
|
||||
<div key={folder} className="rounded-xl border p-4 space-y-3" style={{ borderColor: 'var(--border)', background: 'var(--bg)' }}>
|
||||
{/* Folder header */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Folder size={14} className="text-accent mt-0.5 shrink-0" />
|
||||
<p className="text-sm font-mono flex-1 break-all" style={{ color: 'var(--text)' }}>{folder}</p>
|
||||
<button
|
||||
onClick={() => removeFolder(folder)}
|
||||
className="p-1 rounded transition-colors hover:text-red-400 shrink-0"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
title="Удалить папку"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scan controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => scanFolder(folder)}
|
||||
disabled={isScanning}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--card)', color: 'var(--text)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{isScanning
|
||||
? <><RefreshCw size={12} className="animate-spin" /> Сканирование...</>
|
||||
: <><ScanSearch size={12} /> Сканировать</>}
|
||||
</button>
|
||||
|
||||
{results && (
|
||||
<span className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
найдено: {results.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{importedCount && (
|
||||
<span className="text-xs text-accent flex items-center gap-1">
|
||||
<Check size={12} /> Добавлено: {importedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scan results */}
|
||||
{results && results.length > 0 && (
|
||||
<div>
|
||||
{/* Select all */}
|
||||
<button
|
||||
onClick={() => toggleAll(folder)}
|
||||
className="flex items-center gap-1.5 text-xs mb-2 transition-colors"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
{results.every((e) => sel.has(e.exe))
|
||||
? <CheckSquare size={13} className="text-accent" />
|
||||
: <Square size={13} />}
|
||||
Выбрать все
|
||||
</button>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{results.map((exe) => (
|
||||
<label
|
||||
key={exe.exe}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors hover:bg-cardHover"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sel.has(exe.exe)}
|
||||
onChange={() => toggleExe(folder, exe.exe)}
|
||||
className="accent-accent shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{exe.name}</p>
|
||||
<p className="text-[10px] truncate" style={{ color: 'var(--muted)' }}>{exe.exe}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Import button */}
|
||||
<button
|
||||
onClick={() => importSelected(folder)}
|
||||
disabled={importing || sel.size === 0}
|
||||
className="mt-3 flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent hover:bg-accentHover text-white disabled:opacity-50 w-full justify-center"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{importing ? 'Импорт...' : `Добавить выбранные (${sel.size})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && results.length === 0 && (
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>Игры не найдены в этой папке</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add folder button */}
|
||||
<button
|
||||
onClick={addFolder}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors w-full justify-center border-2 border-dashed hover:border-accent hover:text-accent"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--muted)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Добавить папку
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [light, setLight] = useState(() => {
|
||||
return localStorage.getItem('theme') === 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (light) {
|
||||
document.documentElement.classList.add('light')
|
||||
localStorage.setItem('theme', 'light')
|
||||
} else {
|
||||
document.documentElement.classList.remove('light')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
}
|
||||
}, [light])
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLight((l) => !l)}
|
||||
className="p-2 text-muted hover:text-text transition-colors"
|
||||
title={light ? 'Тёмная тема' : 'Светлая тема'}
|
||||
>
|
||||
{light ? <Moon size={17} /> : <Sun size={17} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+34
-17
@@ -1,35 +1,54 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { CustomGame, Game } from '../types'
|
||||
import type { AppEntry, CustomGame, Game } from '../types'
|
||||
|
||||
interface ScannedExe { name: string; exe: string }
|
||||
interface AppSettings { steamPath: string | null; gameFolders: string[] }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
launcher: {
|
||||
getGames: () => Promise<Game[]>
|
||||
getGames: () => Promise<Game[]>
|
||||
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: CustomGame) => Promise<{ ok: boolean }>
|
||||
pickExe: () => Promise<string | null>
|
||||
pickImage: () => Promise<string | null>
|
||||
onAdminOpen: (cb: () => void) => () => void
|
||||
getFavorites: () => Promise<string[]>
|
||||
// Custom games
|
||||
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
||||
removeGame: (id: string) => Promise<{ ok: boolean }>
|
||||
updateGame: (g: CustomGame) => Promise<{ ok: boolean }>
|
||||
// App entries
|
||||
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => Promise<AppEntry>
|
||||
removeApp: (id: string) => Promise<{ ok: boolean }>
|
||||
updateApp: (a: AppEntry) => Promise<{ ok: boolean }>
|
||||
// Dialogs
|
||||
pickExe: () => Promise<string | null>
|
||||
pickImage: () => Promise<string | null>
|
||||
pickFolder: (title?: string) => Promise<string | null>
|
||||
// Settings
|
||||
getSettings: () => Promise<AppSettings & { resolvedSteamPath: string | null }>
|
||||
setSteamPath: (path: string | null) => Promise<AppSettings>
|
||||
addGameFolder: (folder: string) => Promise<AppSettings>
|
||||
removeGameFolder: (folder: string) => Promise<AppSettings>
|
||||
// Scanner
|
||||
scanFolder: (folder: string) => Promise<ScannedExe[]>
|
||||
importScanned: (exes: ScannedExe[]) => Promise<CustomGame[]>
|
||||
// Favorites / recent
|
||||
getFavorites: () => Promise<string[]>
|
||||
toggleFavorite: (id: string) => Promise<string[]>
|
||||
getRecent: () => Promise<string[]>
|
||||
getRecent: () => Promise<string[]>
|
||||
// Events
|
||||
onAdminOpen: (cb: () => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useGames() {
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await window.launcher.getGames()
|
||||
setGames(data)
|
||||
setGames(await window.launcher.getGames())
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
@@ -37,9 +56,7 @@ export function useGames() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return { games, loading, error, reload: load }
|
||||
}
|
||||
|
||||
+38
-21
@@ -2,35 +2,52 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Theme variables ──────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--card: #1a1a2e;
|
||||
--cardHover: #16213e;
|
||||
--accent: #22c55e;
|
||||
--accentH: #16a34a;
|
||||
--surface: #0f3460;
|
||||
--text: #e2e8f0;
|
||||
--muted: #64748b;
|
||||
--border: #1e293b;
|
||||
--scroll: #1e293b;
|
||||
--scrollH: #334155;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg: #f1f5f9;
|
||||
--card: #ffffff;
|
||||
--cardHover: #f8fafc;
|
||||
--accent: #16a34a;
|
||||
--accentH: #15803d;
|
||||
--surface: #dbeafe;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--scroll: #cbd5e1;
|
||||
--scrollH: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Base ─────────────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #0f0f0f;
|
||||
color: #e2e8f0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #334155;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollH); }
|
||||
}
|
||||
|
||||
+12
-2
@@ -26,9 +26,19 @@ export interface CustomGame {
|
||||
source: 'custom'
|
||||
}
|
||||
|
||||
export type Game = SteamGame | EpicGame | CustomGame
|
||||
export interface AppEntry {
|
||||
id: string
|
||||
name: string
|
||||
exe: string
|
||||
args: string[]
|
||||
image: string
|
||||
category: string
|
||||
source: 'app'
|
||||
}
|
||||
|
||||
export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'favorites' | 'recent'
|
||||
export type Game = SteamGame | EpicGame | CustomGame | AppEntry
|
||||
|
||||
export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'app' | 'favorites' | 'recent'
|
||||
|
||||
export type SortOrder = 'name-asc' | 'name-desc'
|
||||
|
||||
|
||||
+45
-13
@@ -2,35 +2,67 @@ import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{ts,tsx}', './index.html'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#0f0f0f',
|
||||
card: '#1a1a2e',
|
||||
cardHover: '#16213e',
|
||||
accent: '#22c55e',
|
||||
accentHover: '#16a34a',
|
||||
surface: '#0f3460',
|
||||
text: '#e2e8f0',
|
||||
muted: '#64748b',
|
||||
border: '#1e293b',
|
||||
bg: 'var(--bg)',
|
||||
card: 'var(--card)',
|
||||
cardHover: 'var(--cardHover)',
|
||||
accent: 'var(--accent)',
|
||||
accentHover: 'var(--accentH)',
|
||||
surface: 'var(--surface)',
|
||||
text: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
border: 'var(--border)',
|
||||
},
|
||||
aspectRatio: {
|
||||
steam: '460 / 215',
|
||||
app: '1 / 1',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.22s ease-out both',
|
||||
shimmer: 'shimmer 1.4s ease-in-out infinite',
|
||||
'fade-in': 'fadeIn 0.22s ease-out both',
|
||||
'scale-in': 'scaleIn 0.18s ease-out both',
|
||||
'slide-in-right':'slideInRight 0.28s cubic-bezier(0.16,1,0.3,1) both',
|
||||
'slide-in-up': 'slideInUp 0.22s ease-out both',
|
||||
shimmer: 'shimmer 1.4s ease-in-out infinite',
|
||||
'bounce-star': 'bounceStar 0.35s ease-out',
|
||||
ripple: 'ripple 0.5s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
scaleIn: {
|
||||
from: { opacity: '0', transform: 'scale(0.93)' },
|
||||
to: { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
slideInRight: {
|
||||
from: { transform: 'translateX(100%)' },
|
||||
to: { transform: 'translateX(0)' },
|
||||
},
|
||||
slideInUp: {
|
||||
from: { opacity: '0', transform: 'translateY(16px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%, 100%': { opacity: '0.4' },
|
||||
'50%': { opacity: '0.8' },
|
||||
'50%': { opacity: '0.8' },
|
||||
},
|
||||
bounceStar: {
|
||||
'0%': { transform: 'scale(1)' },
|
||||
'40%': { transform: 'scale(1.45)' },
|
||||
'100%': { transform: 'scale(1)' },
|
||||
},
|
||||
ripple: {
|
||||
'0%': { transform: 'scale(0)', opacity: '0.4' },
|
||||
'100%': { transform: 'scale(2.5)', opacity: '0' },
|
||||
},
|
||||
},
|
||||
backdropBlur: { xs: '4px' },
|
||||
transitionTimingFunction: {
|
||||
spring: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user