feat: v1.3.0 — Steam path override, game folder scanner
- electron/settings.ts: persist steamPath + gameFolders in settings.json - electron/scanner.ts: scan folders 2 levels deep for .exe files, filter out known non-game binaries (uninstall/setup/redist/crash handlers) - electron/steam.ts: detectSteamGames(customPath?) — uses user path when set - electron/main.ts: new IPC handlers — dialog:pick-folder, settings:get/set-steam-path/ add-folder/remove-folder, scanner:scan/import - src/components/SettingsPanel.tsx: drawer UI — Steam path picker with auto-resolved path display, game folders list, per-folder scan with checkbox selection, one-click import of selected executables - src/App.tsx: SlidersHorizontal button opens SettingsPanel, reload on import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ dist-electron/
|
|||||||
dist-renderer/
|
dist-renderer/
|
||||||
out/
|
out/
|
||||||
*.blockmap
|
*.blockmap
|
||||||
|
dist/
|
||||||
|
|||||||
+50
-9
@@ -1,15 +1,16 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
|
import { app, BrowserWindow, ipcMain, dialog, globalShortcut } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { detectSteamGames } from './steam'
|
import { detectSteamGames, resolveSteamPath } from './steam'
|
||||||
import { detectEpicGames } from './epic'
|
import { detectEpicGames } from './epic'
|
||||||
import {
|
import {
|
||||||
CustomGame, AppEntry,
|
CustomGame, AppEntry,
|
||||||
readCustomGames, writeCustomGames,
|
readCustomGames, addCustomGame, removeCustomGame, updateCustomGame,
|
||||||
addCustomGame, removeCustomGame, updateCustomGame,
|
|
||||||
readApps, addApp, removeApp, updateApp,
|
readApps, addApp, removeApp, updateApp,
|
||||||
} from './config'
|
} from './config'
|
||||||
import { launchSteamGame, launchEpicGame, launchExe } from './launcher'
|
import { launchSteamGame, launchEpicGame, launchExe } from './launcher'
|
||||||
import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites'
|
import { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites'
|
||||||
|
import { readSettings, updateSettings } from './settings'
|
||||||
|
import { scanFolder } from './scanner'
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ function createWindow(): void {
|
|||||||
// ── IPC ──────────────────────────────────────────────────────────────────
|
// ── IPC ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ipcMain.handle('games:get-all', () => {
|
ipcMain.handle('games:get-all', () => {
|
||||||
const steamGames = detectSteamGames()
|
const { steamPath } = readSettings()
|
||||||
|
const steamGames = detectSteamGames(steamPath)
|
||||||
const epicGames = detectEpicGames()
|
const epicGames = detectEpicGames()
|
||||||
const customGames = readCustomGames()
|
const customGames = readCustomGames()
|
||||||
const apps = readApps()
|
const apps = readApps()
|
||||||
@@ -60,11 +62,9 @@ ipcMain.handle('games:launch', (_event, id: string) => {
|
|||||||
launchEpicGame(id.replace('epic_', ''))
|
launchEpicGame(id.replace('epic_', ''))
|
||||||
return { ok: true, recent: addRecent(id) }
|
return { ok: true, recent: addRecent(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = [...readCustomGames(), ...readApps()]
|
const all = [...readCustomGames(), ...readApps()]
|
||||||
const game = all.find((g) => g.id === id)
|
const game = all.find((g) => g.id === id)
|
||||||
if (!game) return { ok: false, error: 'Not found' }
|
if (!game) return { ok: false, error: 'Not found' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
launchExe(game.exe, game.args)
|
launchExe(game.exe, game.args)
|
||||||
return { ok: true, recent: addRecent(id) }
|
return { ok: true, recent: addRecent(id) }
|
||||||
@@ -102,10 +102,51 @@ ipcMain.handle('dialog:pick-image', async () => {
|
|||||||
return r.canceled ? null : r.filePaths[0]
|
return r.canceled ? null : r.filePaths[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:pick-folder', async (_e, title = 'Выберите папку') => {
|
||||||
|
const r = await dialog.showOpenDialog({ title, properties: ['openDirectory'] })
|
||||||
|
return r.canceled ? null : r.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
// Favorites / recent
|
||||||
ipcMain.handle('favorites:get', () => getFavorites())
|
ipcMain.handle('favorites:get', () => getFavorites())
|
||||||
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
|
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
|
||||||
ipcMain.handle('recent:get', () => getRecent())
|
ipcMain.handle('recent:get', () => getRecent())
|
||||||
|
|
||||||
// ── App lifecycle ────────────────────────────────────────────────────────
|
// ── App lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -1,5 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { CustomGame, AppEntry } from './config'
|
import type { CustomGame, AppEntry } from './config'
|
||||||
|
import type { AppSettings } from './settings'
|
||||||
|
import type { ScannedExe } from './scanner'
|
||||||
|
|
||||||
const launcher = {
|
const launcher = {
|
||||||
// Games
|
// Games
|
||||||
@@ -8,23 +10,34 @@ const launcher = {
|
|||||||
ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>,
|
ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>,
|
||||||
|
|
||||||
// Custom games (admin)
|
// Custom games (admin)
|
||||||
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', g),
|
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', g),
|
||||||
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
|
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
|
||||||
updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g),
|
updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g),
|
||||||
|
|
||||||
// App entries (admin)
|
// App entries (admin)
|
||||||
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => ipcRenderer.invoke('apps:add', a),
|
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => ipcRenderer.invoke('apps:add', a),
|
||||||
removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id),
|
removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id),
|
||||||
updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a),
|
updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a),
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
||||||
pickImage: () => ipcRenderer.invoke('dialog:pick-image') 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
|
// Favorites / recent
|
||||||
getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise<string[]>,
|
getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise<string[]>,
|
||||||
toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise<string[]>,
|
toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise<string[]>,
|
||||||
getRecent: () => ipcRenderer.invoke('recent:get') as Promise<string[]>,
|
getRecent: () => ipcRenderer.invoke('recent:get') as Promise<string[]>,
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
onAdminOpen: (cb: () => void) => {
|
onAdminOpen: (cb: () => void) => {
|
||||||
|
|||||||
@@ -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
|
return games
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectSteamGames(): SteamGame[] {
|
export function detectSteamGames(customSteamPath?: string | null): SteamGame[] {
|
||||||
const steamPath = getSteamPathFromRegistry()
|
const steamPath = customSteamPath && existsSync(customSteamPath)
|
||||||
|
? customSteamPath
|
||||||
|
: getSteamPathFromRegistry()
|
||||||
|
|
||||||
if (!steamPath) return []
|
if (!steamPath) return []
|
||||||
|
|
||||||
const libraryFolders = getLibraryFolders(steamPath)
|
const libraryFolders = getLibraryFolders(steamPath)
|
||||||
@@ -177,3 +180,9 @@ export function detectSteamGames(): SteamGame[] {
|
|||||||
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
games.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||||
return games
|
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",
|
"name": "club-launcher",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "club-launcher",
|
"name": "club-launcher",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-steam/vdf": "^2.0.1",
|
"@node-steam/vdf": "^2.0.1",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "club-launcher",
|
"name": "club-launcher",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "Game launcher for computer club",
|
"description": "Game launcher for computer club",
|
||||||
"author": "houseassassin",
|
"author": "houseassassin",
|
||||||
"main": "out/main/main.js",
|
"main": "out/main/main.js",
|
||||||
|
|||||||
+21
-4
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
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 { useGames } from './hooks/useGames'
|
||||||
import { GameGrid } from './components/GameGrid'
|
import { GameGrid } from './components/GameGrid'
|
||||||
import { SearchBar } from './components/SearchBar'
|
import { SearchBar } from './components/SearchBar'
|
||||||
import { CategoryFilter } from './components/CategoryFilter'
|
import { CategoryFilter } from './components/CategoryFilter'
|
||||||
import { AdminPanel } from './components/AdminPanel'
|
import { AdminPanel } from './components/AdminPanel'
|
||||||
|
import { SettingsPanel } from './components/SettingsPanel'
|
||||||
import { ThemeToggle } from './components/ThemeToggle'
|
import { ThemeToggle } from './components/ThemeToggle'
|
||||||
import type { AppEntry, Category, CustomGame, Game, SortOrder } from './types'
|
import type { AppEntry, Category, CustomGame, Game, SortOrder } from './types'
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ export default function App() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [category, setCategory] = useState<Category>('all')
|
const [category, setCategory] = useState<Category>('all')
|
||||||
const [sort, setSort] = useState<SortOrder>('name-asc')
|
const [sort, setSort] = useState<SortOrder>('name-asc')
|
||||||
const [adminOpen, setAdminOpen] = useState(false)
|
const [adminOpen, setAdminOpen] = useState(false)
|
||||||
const [adminMode, setAdminMode] = useState(false)
|
const [adminMode, setAdminMode] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [favorites, setFavorites] = useState<string[]>([])
|
const [favorites, setFavorites] = useState<string[]>([])
|
||||||
const [recent, setRecent] = useState<string[]>([])
|
const [recent, setRecent] = useState<string[]>([])
|
||||||
|
|
||||||
@@ -161,10 +163,18 @@ export default function App() {
|
|||||||
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
className="p-2 text-muted hover:text-text transition-colors"
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={17} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setAdminOpen(true); setAdminMode(true) }}
|
onClick={() => { setAdminOpen(true); setAdminMode(true) }}
|
||||||
className="p-2 text-muted hover:text-accent transition-colors"
|
className="p-2 text-muted hover:text-accent transition-colors"
|
||||||
title="Управление (Ctrl+Alt+A)"
|
title="Управление играми (Ctrl+Alt+A)"
|
||||||
>
|
>
|
||||||
<Settings size={17} />
|
<Settings size={17} />
|
||||||
</button>
|
</button>
|
||||||
@@ -189,6 +199,13 @@ export default function App() {
|
|||||||
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsPanel
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onImported={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{adminOpen && (
|
{adminOpen && (
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
games={games}
|
games={games}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+19
-8
@@ -1,22 +1,34 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import type { AppEntry, 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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
launcher: {
|
launcher: {
|
||||||
getGames: () => Promise<Game[]>
|
getGames: () => Promise<Game[]>
|
||||||
launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }>
|
launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }>
|
||||||
// Custom games
|
// Custom games
|
||||||
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
||||||
removeGame: (id: string) => Promise<{ ok: boolean }>
|
removeGame: (id: string) => Promise<{ ok: boolean }>
|
||||||
updateGame: (g: CustomGame) => Promise<{ ok: boolean }>
|
updateGame: (g: CustomGame) => Promise<{ ok: boolean }>
|
||||||
// App entries
|
// App entries
|
||||||
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => Promise<AppEntry>
|
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => Promise<AppEntry>
|
||||||
removeApp: (id: string) => Promise<{ ok: boolean }>
|
removeApp: (id: string) => Promise<{ ok: boolean }>
|
||||||
updateApp: (a: AppEntry) => Promise<{ ok: boolean }>
|
updateApp: (a: AppEntry) => Promise<{ ok: boolean }>
|
||||||
// Dialogs
|
// Dialogs
|
||||||
pickExe: () => Promise<string | null>
|
pickExe: () => Promise<string | null>
|
||||||
pickImage: () => 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
|
// Favorites / recent
|
||||||
getFavorites: () => Promise<string[]>
|
getFavorites: () => Promise<string[]>
|
||||||
toggleFavorite: (id: string) => Promise<string[]>
|
toggleFavorite: (id: string) => Promise<string[]>
|
||||||
@@ -36,8 +48,7 @@ export function useGames() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await window.launcher.getGames()
|
setGames(await window.launcher.getGames())
|
||||||
setGames(data)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user