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/
|
||||
out/
|
||||
*.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 { detectSteamGames } from './steam'
|
||||
import { detectSteamGames, resolveSteamPath } from './steam'
|
||||
import { detectEpicGames } from './epic'
|
||||
import {
|
||||
CustomGame, AppEntry,
|
||||
readCustomGames, writeCustomGames,
|
||||
addCustomGame, removeCustomGame, updateCustomGame,
|
||||
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
|
||||
|
||||
@@ -44,7 +45,8 @@ function createWindow(): void {
|
||||
// ── IPC ──────────────────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('games:get-all', () => {
|
||||
const steamGames = detectSteamGames()
|
||||
const { steamPath } = readSettings()
|
||||
const steamGames = detectSteamGames(steamPath)
|
||||
const epicGames = detectEpicGames()
|
||||
const customGames = readCustomGames()
|
||||
const apps = readApps()
|
||||
@@ -60,11 +62,9 @@ ipcMain.handle('games:launch', (_event, id: string) => {
|
||||
launchEpicGame(id.replace('epic_', ''))
|
||||
return { ok: true, recent: addRecent(id) }
|
||||
}
|
||||
|
||||
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)
|
||||
return { ok: true, recent: addRecent(id) }
|
||||
@@ -102,10 +102,51 @@ ipcMain.handle('dialog:pick-image', async () => {
|
||||
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
|
||||
ipcMain.handle('favorites:get', () => getFavorites())
|
||||
ipcMain.handle('favorites:get', () => getFavorites())
|
||||
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
|
||||
ipcMain.handle('recent:get', () => getRecent())
|
||||
ipcMain.handle('recent:get', () => getRecent())
|
||||
|
||||
// ── App lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+23
-10
@@ -1,5 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { CustomGame, AppEntry } from './config'
|
||||
import type { AppSettings } from './settings'
|
||||
import type { ScannedExe } from './scanner'
|
||||
|
||||
const launcher = {
|
||||
// Games
|
||||
@@ -8,23 +10,34 @@ const launcher = {
|
||||
ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>,
|
||||
|
||||
// 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),
|
||||
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),
|
||||
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>,
|
||||
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[]>,
|
||||
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) => {
|
||||
|
||||
@@ -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.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "club-launcher",
|
||||
"version": "1.2.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.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "Game launcher for computer club",
|
||||
"author": "houseassassin",
|
||||
"main": "out/main/main.js",
|
||||
|
||||
+21
-4
@@ -1,10 +1,11 @@
|
||||
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 { SettingsPanel } from './components/SettingsPanel'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import type { AppEntry, Category, CustomGame, Game, SortOrder } from './types'
|
||||
|
||||
@@ -14,8 +15,9 @@ export default function App() {
|
||||
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 [adminOpen, setAdminOpen] = useState(false)
|
||||
const [adminMode, setAdminMode] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [favorites, setFavorites] = useState<string[]>([])
|
||||
const [recent, setRecent] = useState<string[]>([])
|
||||
|
||||
@@ -161,10 +163,18 @@ export default function App() {
|
||||
<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="Управление (Ctrl+Alt+A)"
|
||||
title="Управление играми (Ctrl+Alt+A)"
|
||||
>
|
||||
<Settings size={17} />
|
||||
</button>
|
||||
@@ -189,6 +199,13 @@ export default function App() {
|
||||
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
||||
</footer>
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onImported={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adminOpen && (
|
||||
<AdminPanel
|
||||
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 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[] }>
|
||||
// Custom games
|
||||
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
||||
removeGame: (id: string) => Promise<{ ok: boolean }>
|
||||
updateGame: (g: CustomGame) => Promise<{ ok: boolean }>
|
||||
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>
|
||||
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[]>
|
||||
@@ -36,8 +48,7 @@ export function useGames() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await window.launcher.getGames()
|
||||
setGames(data)
|
||||
setGames(await window.launcher.getGames())
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user