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:
2026-06-02 13:05:29 +00:00
parent 35c61c2496
commit b84a7bdf29
11 changed files with 609 additions and 36 deletions
+1
View File
@@ -4,3 +4,4 @@ dist-electron/
dist-renderer/ dist-renderer/
out/ out/
*.blockmap *.blockmap
dist/
+50 -9
View File
@@ -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
View File
@@ -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) => {
+96
View File
@@ -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
}
+42
View File
@@ -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
View File
@@ -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()
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+343
View File
@@ -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
View File
@@ -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 {