feat: v1.2.0 — Apps tab, light/dark theme, visual overhaul, animations
- Apps tab: new AppEntry type (source: 'app'), apps.json storage, IPC handlers (apps:add/remove/update), square tile layout in grid, blue APP badge - Light/dark theme: CSS variables in :root/.light, ThemeToggle (Sun/Moon), persisted in localStorage, smooth 0.2s transition - Visual overhaul: gradient logo, pill badges, glassmorphism header, gradient play button, modern AdminPanel as slide-in drawer from right - Animations: 3D tilt + glow on hover, bounce on favorite star, ripple on play, slide indicator in CategoryFilter, per-category grid re-animation - AdminPanel: ESC to close, slide-in/out animation, Game/App toggle in add form, separate sections for games and apps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+77
-19
@@ -1,6 +1,7 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join, sep } from 'path'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
export interface CustomGame {
|
export interface CustomGame {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,36 +13,93 @@ export interface CustomGame {
|
|||||||
source: 'custom'
|
source: 'custom'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GamesConfig {
|
export interface AppEntry {
|
||||||
games: CustomGame[]
|
id: string
|
||||||
|
name: string
|
||||||
|
exe: string
|
||||||
|
args: string[]
|
||||||
|
image: string
|
||||||
|
category: string
|
||||||
|
source: 'app'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigPath(): string {
|
function getUserDataPath(filename: string): string {
|
||||||
const dir = app.getPath('userData')
|
const dir = app.getPath('userData')
|
||||||
return join(dir, 'games.json')
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
return join(dir, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readJsonList<T>(filename: string): T[] {
|
||||||
|
const path = getUserDataPath(filename)
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
return Array.isArray(data?.items) ? data.items : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonList<T>(filename: string, items: T[]): void {
|
||||||
|
writeFileSync(getUserDataPath(filename), JSON.stringify({ items }, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom games ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function readCustomGames(): CustomGame[] {
|
export function readCustomGames(): CustomGame[] {
|
||||||
const path = getConfigPath()
|
// Support both old format (games.json with { games: [] }) and new format
|
||||||
|
const path = getUserDataPath('games.json')
|
||||||
if (!existsSync(path)) return []
|
if (!existsSync(path)) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(path, 'utf-8')
|
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
const data = JSON.parse(raw) as GamesConfig
|
if (Array.isArray(data?.games)) return data.games // legacy
|
||||||
return Array.isArray(data.games) ? data.games : []
|
if (Array.isArray(data?.items)) return data.items // new
|
||||||
|
return []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeCustomGames(games: CustomGame[]): void {
|
export function writeCustomGames(games: CustomGame[]): void {
|
||||||
const path = getConfigPath()
|
writeFileSync(getUserDataPath('games.json'), JSON.stringify({ items: games }, null, 2), 'utf-8')
|
||||||
const dir = path.substring(0, path.lastIndexOf(require('path').sep))
|
}
|
||||||
|
|
||||||
if (!existsSync(dir)) {
|
export function addCustomGame(data: Omit<CustomGame, 'id' | 'source'>): CustomGame {
|
||||||
mkdirSync(dir, { recursive: true })
|
const games = readCustomGames()
|
||||||
}
|
const newGame: CustomGame = { ...data, id: uuidv4(), source: 'custom' }
|
||||||
|
writeCustomGames([...games, newGame])
|
||||||
const data: GamesConfig = { games }
|
return newGame
|
||||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8')
|
}
|
||||||
|
|
||||||
|
export function removeCustomGame(id: string): void {
|
||||||
|
writeCustomGames(readCustomGames().filter((g) => g.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCustomGame(updated: CustomGame): void {
|
||||||
|
writeCustomGames(readCustomGames().map((g) => (g.id === updated.id ? { ...g, ...updated } : g)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App entries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function readApps(): AppEntry[] {
|
||||||
|
return readJsonList<AppEntry>('apps.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeApps(apps: AppEntry[]): void {
|
||||||
|
writeJsonList('apps.json', apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addApp(data: Omit<AppEntry, 'id' | 'source'>): AppEntry {
|
||||||
|
const apps = readApps()
|
||||||
|
const newApp: AppEntry = { ...data, id: uuidv4(), source: 'app' }
|
||||||
|
writeApps([...apps, newApp])
|
||||||
|
return newApp
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeApp(id: string): void {
|
||||||
|
writeApps(readApps().filter((a) => a.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateApp(updated: AppEntry): void {
|
||||||
|
writeApps(readApps().map((a) => (a.id === updated.id ? { ...a, ...updated } : a)))
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-59
@@ -1,9 +1,13 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
|
import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { detectSteamGames } from './steam'
|
import { detectSteamGames } from './steam'
|
||||||
import { detectEpicGames } from './epic'
|
import { detectEpicGames } from './epic'
|
||||||
import { readCustomGames, writeCustomGames, CustomGame } from './config'
|
import {
|
||||||
|
CustomGame, AppEntry,
|
||||||
|
readCustomGames, writeCustomGames,
|
||||||
|
addCustomGame, removeCustomGame, updateCustomGame,
|
||||||
|
readApps, addApp, removeApp, updateApp,
|
||||||
|
} 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'
|
||||||
|
|
||||||
@@ -34,105 +38,82 @@ function createWindow(): void {
|
|||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => { mainWindow = null })
|
||||||
mainWindow = null
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IPC Handlers ────────────────────────────────────────────────────────────
|
// ── IPC ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ipcMain.handle('games:get-all', () => {
|
ipcMain.handle('games:get-all', () => {
|
||||||
const steamGames = detectSteamGames()
|
const steamGames = detectSteamGames()
|
||||||
const epicGames = detectEpicGames()
|
const epicGames = detectEpicGames()
|
||||||
const customGames = readCustomGames()
|
const customGames = readCustomGames()
|
||||||
return [...steamGames, ...epicGames, ...customGames]
|
const apps = readApps()
|
||||||
|
return [...steamGames, ...epicGames, ...customGames, ...apps]
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('games:launch', (_event, id: string) => {
|
ipcMain.handle('games:launch', (_event, id: string) => {
|
||||||
if (id.startsWith('steam_')) {
|
if (id.startsWith('steam_')) {
|
||||||
const appid = id.replace('steam_', '')
|
launchSteamGame(id.replace('steam_', ''))
|
||||||
launchSteamGame(appid)
|
return { ok: true, recent: addRecent(id) }
|
||||||
const recent = addRecent(id)
|
|
||||||
return { ok: true, recent }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id.startsWith('epic_')) {
|
if (id.startsWith('epic_')) {
|
||||||
const appName = id.replace('epic_', '')
|
launchEpicGame(id.replace('epic_', ''))
|
||||||
launchEpicGame(appName)
|
return { ok: true, recent: addRecent(id) }
|
||||||
const recent = addRecent(id)
|
|
||||||
return { ok: true, recent }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customs = readCustomGames()
|
const all = [...readCustomGames(), ...readApps()]
|
||||||
const game = customs.find((g) => g.id === id)
|
const game = all.find((g) => g.id === id)
|
||||||
if (!game) return { ok: false, error: 'Game not found' }
|
if (!game) return { ok: false, error: 'Not found' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
launchExe(game.exe, game.args)
|
launchExe(game.exe, game.args)
|
||||||
const recent = addRecent(id)
|
return { ok: true, recent: addRecent(id) }
|
||||||
return { ok: true, recent }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e) }
|
return { ok: false, error: String(e) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('admin:add-game', (_event, game: Omit<CustomGame, 'id' | 'source'>) => {
|
// Custom games
|
||||||
const customs = readCustomGames()
|
ipcMain.handle('admin:add-game', (_e, d: Omit<CustomGame, 'id' | 'source'>) => addCustomGame(d))
|
||||||
const newGame: CustomGame = {
|
ipcMain.handle('admin:remove-game', (_e, id: string) => { removeCustomGame(id); return { ok: true } })
|
||||||
...game,
|
ipcMain.handle('admin:update-game', (_e, g: CustomGame) => { updateCustomGame(g); return { ok: true } })
|
||||||
id: uuidv4(),
|
|
||||||
source: 'custom',
|
|
||||||
args: game.args ?? [],
|
|
||||||
image: game.image ?? '',
|
|
||||||
category: game.category ?? 'Other',
|
|
||||||
}
|
|
||||||
writeCustomGames([...customs, newGame])
|
|
||||||
return newGame
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('admin:remove-game', (_event, id: string) => {
|
// App entries
|
||||||
const customs = readCustomGames()
|
ipcMain.handle('apps:add', (_e, d: Omit<AppEntry, 'id' | 'source'>) => addApp(d))
|
||||||
writeCustomGames(customs.filter((g) => g.id !== id))
|
ipcMain.handle('apps:remove', (_e, id: string) => { removeApp(id); return { ok: true } })
|
||||||
return { ok: true }
|
ipcMain.handle('apps:update', (_e, a: AppEntry) => { updateApp(a); return { ok: true } })
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('admin:update-game', (_event, updated: CustomGame) => {
|
|
||||||
const customs = readCustomGames()
|
|
||||||
writeCustomGames(customs.map((g) => (g.id === updated.id ? { ...g, ...updated } : g)))
|
|
||||||
return { ok: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
ipcMain.handle('dialog:pick-exe', async () => {
|
ipcMain.handle('dialog:pick-exe', async () => {
|
||||||
const result = await dialog.showOpenDialog({
|
const r = await dialog.showOpenDialog({
|
||||||
title: 'Выберите исполняемый файл',
|
title: 'Выберите исполняемый файл',
|
||||||
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd'] }],
|
filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd', 'lnk'] }],
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
})
|
})
|
||||||
return result.canceled ? null : result.filePaths[0]
|
return r.canceled ? null : r.filePaths[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dialog:pick-image', async () => {
|
ipcMain.handle('dialog:pick-image', async () => {
|
||||||
const result = await dialog.showOpenDialog({
|
const r = await dialog.showOpenDialog({
|
||||||
title: 'Выберите обложку',
|
title: 'Выберите обложку',
|
||||||
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }],
|
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'ico'] }],
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
})
|
})
|
||||||
return result.canceled ? null : result.filePaths[0]
|
return r.canceled ? null : r.filePaths[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('favorites:get', () => getFavorites())
|
// Favorites / recent
|
||||||
ipcMain.handle('favorites:toggle', (_event, id: string) => toggleFavorite(id))
|
ipcMain.handle('favorites:get', () => getFavorites())
|
||||||
ipcMain.handle('recent:get', () => getRecent())
|
ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id))
|
||||||
|
ipcMain.handle('recent:get', () => getRecent())
|
||||||
|
|
||||||
// ── App lifecycle ────────────────────────────────────────────────────────────
|
// ── App lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
globalShortcut.register('CommandOrControl+Alt+A', () => {
|
globalShortcut.register('CommandOrControl+Alt+A', () => {
|
||||||
mainWindow?.webContents.send('admin:open')
|
mainWindow?.webContents.send('admin:open')
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-10
@@ -1,24 +1,37 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { CustomGame } from './config'
|
import type { CustomGame, AppEntry } from './config'
|
||||||
|
|
||||||
const launcher = {
|
const launcher = {
|
||||||
getGames: () => ipcRenderer.invoke('games:get-all'),
|
// Games
|
||||||
|
getGames: () => ipcRenderer.invoke('games:get-all'),
|
||||||
launchGame: (id: string) =>
|
launchGame: (id: string) =>
|
||||||
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[] }>,
|
||||||
addCustomGame: (game: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', game),
|
|
||||||
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
|
// Custom games (admin)
|
||||||
updateGame: (game: CustomGame) => ipcRenderer.invoke('admin:update-game', game),
|
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => ipcRenderer.invoke('admin:add-game', g),
|
||||||
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id),
|
||||||
|
updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g),
|
||||||
|
|
||||||
|
// App entries (admin)
|
||||||
|
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => ipcRenderer.invoke('apps:add', a),
|
||||||
|
removeApp: (id: string) => ipcRenderer.invoke('apps:remove', id),
|
||||||
|
updateApp: (a: AppEntry) => ipcRenderer.invoke('apps:update', a),
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise<string | null>,
|
||||||
pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise<string | null>,
|
pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise<string | null>,
|
||||||
|
|
||||||
|
// Favorites / recent
|
||||||
|
getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise<string[]>,
|
||||||
|
toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise<string[]>,
|
||||||
|
getRecent: () => ipcRenderer.invoke('recent:get') as Promise<string[]>,
|
||||||
|
|
||||||
|
// Events
|
||||||
onAdminOpen: (cb: () => void) => {
|
onAdminOpen: (cb: () => void) => {
|
||||||
ipcRenderer.on('admin:open', cb)
|
ipcRenderer.on('admin:open', cb)
|
||||||
return () => ipcRenderer.removeListener('admin:open', cb)
|
return () => ipcRenderer.removeListener('admin:open', cb)
|
||||||
},
|
},
|
||||||
getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise<string[]>,
|
|
||||||
toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise<string[]>,
|
|
||||||
getRecent: () => ipcRenderer.invoke('recent:get') as Promise<string[]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('launcher', launcher)
|
contextBridge.exposeInMainWorld('launcher', launcher)
|
||||||
|
|
||||||
export type LauncherAPI = typeof launcher
|
export type LauncherAPI = typeof launcher
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "club-launcher",
|
"name": "club-launcher",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "club-launcher",
|
"name": "club-launcher",
|
||||||
"version": "1.1.0",
|
"version": "1.2.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.1.0",
|
"version": "1.2.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",
|
||||||
|
|||||||
+64
-49
@@ -5,17 +5,26 @@ 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 type { Category, CustomGame, Game, SortOrder } from './types'
|
import { ThemeToggle } from './components/ThemeToggle'
|
||||||
|
import type { AppEntry, Category, CustomGame, Game, SortOrder } from './types'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { games, loading, reload } = useGames()
|
const { games, loading, reload } = useGames()
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [category, setCategory] = useState<Category>('all')
|
const [search, setSearch] = useState('')
|
||||||
const [sort, setSort] = useState<SortOrder>('name-asc')
|
const [category, setCategory] = useState<Category>('all')
|
||||||
|
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 [favorites, setFavorites] = useState<string[]>([])
|
const [favorites, setFavorites] = useState<string[]>([])
|
||||||
const [recent, setRecent] = useState<string[]>([])
|
const [recent, setRecent] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Load initial theme from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem('theme') === 'light') {
|
||||||
|
document.documentElement.classList.add('light')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.launcher.getFavorites().then(setFavorites)
|
window.launcher.getFavorites().then(setFavorites)
|
||||||
@@ -23,22 +32,22 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.launcher.onAdminOpen(() => {
|
return window.launcher.onAdminOpen(() => {
|
||||||
setAdminOpen(true)
|
setAdminOpen(true)
|
||||||
setAdminMode(true)
|
setAdminMode(true)
|
||||||
})
|
})
|
||||||
return unsubscribe
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const counts = useMemo((): Record<Category, number> => {
|
const counts = useMemo((): Record<Category, number> => {
|
||||||
const recentSet = new Set(recent)
|
const recentSet = new Set(recent)
|
||||||
return {
|
return {
|
||||||
all: games.length,
|
all: games.length,
|
||||||
steam: games.filter((g) => g.source === 'steam').length,
|
steam: games.filter((g) => g.source === 'steam').length,
|
||||||
epic: games.filter((g) => g.source === 'epic').length,
|
epic: games.filter((g) => g.source === 'epic').length,
|
||||||
custom: games.filter((g) => g.source === 'custom').length,
|
custom: games.filter((g) => g.source === 'custom').length,
|
||||||
|
app: games.filter((g) => g.source === 'app').length,
|
||||||
favorites: games.filter((g) => favorites.includes(g.id)).length,
|
favorites: games.filter((g) => favorites.includes(g.id)).length,
|
||||||
recent: games.filter((g) => recentSet.has(g.id)).length,
|
recent: games.filter((g) => recentSet.has(g.id)).length,
|
||||||
}
|
}
|
||||||
}, [games, favorites, recent])
|
}, [games, favorites, recent])
|
||||||
|
|
||||||
@@ -49,9 +58,9 @@ export default function App() {
|
|||||||
list = games.filter((g) => favorites.includes(g.id))
|
list = games.filter((g) => favorites.includes(g.id))
|
||||||
} else if (category === 'recent') {
|
} else if (category === 'recent') {
|
||||||
const recentMap = new Map(recent.map((id, i) => [id, i]))
|
const recentMap = new Map(recent.map((id, i) => [id, i]))
|
||||||
list = games.filter((g) => recentMap.has(g.id))
|
list = games
|
||||||
list.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999))
|
.filter((g) => recentMap.has(g.id))
|
||||||
// Apply search but skip sort (recent order preserved)
|
.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999))
|
||||||
if (search) {
|
if (search) {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
list = list.filter((g) => g.name.toLowerCase().includes(q))
|
list = list.filter((g) => g.name.toLowerCase().includes(q))
|
||||||
@@ -73,7 +82,6 @@ export default function App() {
|
|||||||
? a.name.localeCompare(b.name, 'ru')
|
? a.name.localeCompare(b.name, 'ru')
|
||||||
: b.name.localeCompare(a.name, 'ru'),
|
: b.name.localeCompare(a.name, 'ru'),
|
||||||
)
|
)
|
||||||
|
|
||||||
return list
|
return list
|
||||||
}, [games, category, search, sort, favorites, recent])
|
}, [games, category, search, sort, favorites, recent])
|
||||||
|
|
||||||
@@ -83,40 +91,41 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(async (id: string) => {
|
const handleToggleFavorite = useCallback(async (id: string) => {
|
||||||
const updated = await window.launcher.toggleFavorite(id)
|
setFavorites(await window.launcher.toggleFavorite(id))
|
||||||
setFavorites(updated)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAdd = useCallback(async (payload: Omit<CustomGame, 'id' | 'source'>) => {
|
// Custom games
|
||||||
await window.launcher.addCustomGame(payload)
|
const handleAdd = useCallback(async (g: Omit<CustomGame, 'id' | 'source'>) => { await window.launcher.addCustomGame(g); await reload() }, [reload])
|
||||||
await reload()
|
const handleRemove = useCallback(async (id: string) => { await window.launcher.removeGame(id); await reload() }, [reload])
|
||||||
}, [reload])
|
const handleUpdate = useCallback(async (g: CustomGame) => { await window.launcher.updateGame(g); await reload() }, [reload])
|
||||||
|
|
||||||
const handleRemove = useCallback(async (id: string) => {
|
// App entries
|
||||||
await window.launcher.removeGame(id)
|
const handleAddApp = useCallback(async (a: Omit<AppEntry, 'id' | 'source'>) => { await window.launcher.addApp(a); await reload() }, [reload])
|
||||||
await reload()
|
const handleRemoveApp = useCallback(async (id: string) => { await window.launcher.removeApp(id); await reload() }, [reload])
|
||||||
}, [reload])
|
const handleUpdateApp = useCallback(async (a: AppEntry) => { await window.launcher.updateApp(a); await reload() }, [reload])
|
||||||
|
|
||||||
const handleUpdate = useCallback(async (game: CustomGame) => {
|
const closeAdmin = () => { setAdminOpen(false); setAdminMode(false) }
|
||||||
await window.launcher.updateGame(game)
|
|
||||||
await reload()
|
|
||||||
}, [reload])
|
|
||||||
|
|
||||||
const closeAdmin = () => {
|
const hasEpic = counts.epic > 0
|
||||||
setAdminOpen(false)
|
|
||||||
setAdminMode(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasEpic = counts.epic > 0
|
|
||||||
const hasFavorites = favorites.length > 0
|
const hasFavorites = favorites.length > 0
|
||||||
const hasRecent = recent.length > 0
|
const hasRecent = recent.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--bg)', color: 'var(--text)' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center gap-3 px-6 py-3 bg-card border-b border-border shrink-0">
|
<header
|
||||||
|
className="flex items-center gap-3 px-6 py-3 border-b shrink-0"
|
||||||
|
style={{
|
||||||
|
background: 'var(--card)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 mr-1">
|
<div className="flex items-center gap-2 mr-1">
|
||||||
<Gamepad2 size={22} className="text-accent" />
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent to-accentHover flex items-center justify-center shadow-md shadow-accent/30">
|
||||||
|
<Gamepad2 size={16} className="text-white" />
|
||||||
|
</div>
|
||||||
<span className="font-bold text-base tracking-wide">Club Launcher</span>
|
<span className="font-bold text-base tracking-wide">Club Launcher</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,20 +142,21 @@ export default function App() {
|
|||||||
|
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
|
|
||||||
{/* Sort toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSort((s) => (s === 'name-asc' ? 'name-desc' : 'name-asc'))}
|
onClick={() => setSort((s) => (s === 'name-asc' ? 'name-desc' : 'name-asc'))}
|
||||||
className="p-2 text-muted hover:text-text transition-colors"
|
className="p-2 text-muted hover:text-text transition-colors"
|
||||||
title={sort === 'name-asc' ? 'Сортировка А→Я' : 'Сортировка Я→А'}
|
title={sort === 'name-asc' ? 'А→Я' : 'Я→А'}
|
||||||
>
|
>
|
||||||
{sort === 'name-asc' ? <ArrowDownAZ size={17} /> : <ArrowUpZA size={17} />}
|
{sort === 'name-asc' ? <ArrowDownAZ size={17} /> : <ArrowUpZA size={17} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-2 text-muted hover:text-text transition-colors disabled:opacity-40"
|
className="p-2 text-muted hover:text-text transition-colors disabled:opacity-40"
|
||||||
title="Обновить библиотеку"
|
title="Обновить"
|
||||||
>
|
>
|
||||||
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
@@ -154,7 +164,7 @@ export default function App() {
|
|||||||
<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="Управление играми"
|
title="Управление (Ctrl+Alt+A)"
|
||||||
>
|
>
|
||||||
<Settings size={17} />
|
<Settings size={17} />
|
||||||
</button>
|
</button>
|
||||||
@@ -168,13 +178,15 @@ export default function App() {
|
|||||||
onEdit={() => { if (!adminOpen) setAdminOpen(true) }}
|
onEdit={() => { if (!adminOpen) setAdminOpen(true) }}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
adminMode={adminMode}
|
adminMode={adminMode}
|
||||||
|
category={category}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer className="px-6 py-2 bg-card border-t border-border shrink-0">
|
<footer
|
||||||
<p className="text-muted text-xs">
|
className="px-6 py-2 border-t shrink-0 text-xs"
|
||||||
{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} игр`}
|
style={{ background: 'var(--card)', borderColor: 'var(--border)', color: 'var(--muted)' }}
|
||||||
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
>
|
||||||
</p>
|
{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} записей`}
|
||||||
|
{adminMode && <span className="ml-3 text-accent">● Режим администратора</span>}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{adminOpen && (
|
{adminOpen && (
|
||||||
@@ -184,6 +196,9 @@ export default function App() {
|
|||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
|
onAddApp={handleAddApp}
|
||||||
|
onRemoveApp={handleRemoveApp}
|
||||||
|
onUpdateApp={handleUpdateApp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+179
-136
@@ -1,16 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, Plus, Trash2, Edit2, FolderOpen, Image } from 'lucide-react'
|
import { X, Plus, Trash2, Edit2, FolderOpen, Image, Gamepad2, AppWindow } from 'lucide-react'
|
||||||
import type { CustomGame, Game, GameFormData } from '../types'
|
import type { AppEntry, CustomGame, Game, GameFormData } from '../types'
|
||||||
|
|
||||||
const CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
|
const GAME_CATEGORIES = ['FPS', 'MMO', 'Racing', 'Strategy', 'Battle Royale', 'Sports', 'RPG', 'Other']
|
||||||
|
const APP_CATEGORIES = ['Браузер', 'Мессенджер', 'Инструменты', 'Медиа', 'Другое']
|
||||||
|
|
||||||
const EMPTY_FORM: GameFormData = {
|
const EMPTY_FORM: GameFormData = { name: '', exe: '', args: '', image: '', category: 'Other' }
|
||||||
name: '',
|
|
||||||
exe: '',
|
type EntryKind = 'game' | 'app'
|
||||||
args: '',
|
type Mode = 'list' | 'add' | 'edit'
|
||||||
image: '',
|
|
||||||
category: 'Other',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
games: Game[]
|
games: Game[]
|
||||||
@@ -18,68 +16,89 @@ interface Props {
|
|||||||
onAdd: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<void>
|
onAdd: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<void>
|
||||||
onRemove: (id: string) => Promise<void>
|
onRemove: (id: string) => Promise<void>
|
||||||
onUpdate: (game: CustomGame) => Promise<void>
|
onUpdate: (game: CustomGame) => Promise<void>
|
||||||
|
onAddApp: (app: Omit<AppEntry, 'id' | 'source'>) => Promise<void>
|
||||||
|
onRemoveApp: (id: string) => Promise<void>
|
||||||
|
onUpdateApp: (app: AppEntry) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'list' | 'add' | 'edit'
|
export function AdminPanel({
|
||||||
|
games, onClose,
|
||||||
|
onAdd, onRemove, onUpdate,
|
||||||
|
onAddApp, onRemoveApp, onUpdateApp,
|
||||||
|
}: Props) {
|
||||||
|
const [mode, setMode] = useState<Mode>('list')
|
||||||
|
const [kind, setKind] = useState<EntryKind>('game')
|
||||||
|
const [form, setForm] = useState<GameFormData>(EMPTY_FORM)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) {
|
// Animate in
|
||||||
const [mode, setMode] = useState<Mode>('list')
|
useEffect(() => { requestAnimationFrame(() => setVisible(true)) }, [])
|
||||||
const [form, setForm] = useState<GameFormData>(EMPTY_FORM)
|
|
||||||
const [editId, setEditId] = useState<string | null>(null)
|
// Close on Escape
|
||||||
const [saving, setSaving] = useState(false)
|
useEffect(() => {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false)
|
||||||
|
setTimeout(onClose, 280)
|
||||||
|
}
|
||||||
|
|
||||||
const customGames = games.filter((g): g is CustomGame => g.source === 'custom')
|
const customGames = games.filter((g): g is CustomGame => g.source === 'custom')
|
||||||
|
const appEntries = games.filter((g): g is AppEntry => g.source === 'app')
|
||||||
|
|
||||||
const startAdd = () => {
|
const startAdd = (k: EntryKind) => {
|
||||||
setForm(EMPTY_FORM)
|
const defaultCat = k === 'game' ? 'Other' : 'Другое'
|
||||||
|
setForm({ ...EMPTY_FORM, category: defaultCat })
|
||||||
|
setKind(k)
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setMode('add')
|
setMode('add')
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (game: CustomGame) => {
|
const startEdit = (game: CustomGame | AppEntry) => {
|
||||||
setForm({
|
setForm({
|
||||||
name: game.name,
|
name: game.name,
|
||||||
exe: game.exe,
|
exe: game.exe,
|
||||||
args: game.args.join(' '),
|
args: game.args.join(' '),
|
||||||
image: game.image,
|
image: game.image,
|
||||||
category: game.category,
|
category: game.category,
|
||||||
})
|
})
|
||||||
|
setKind(game.source === 'app' ? 'app' : 'game')
|
||||||
setEditId(game.id)
|
setEditId(game.id)
|
||||||
setError(null)
|
setError(null)
|
||||||
setMode('edit')
|
setMode('edit')
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickExe = async () => {
|
const pickExe = async () => { const p = await window.launcher.pickExe(); if (p) setForm((f) => ({ ...f, exe: p })) }
|
||||||
const path = await window.launcher.pickExe()
|
const pickImage = async () => { const p = await window.launcher.pickImage(); if (p) setForm((f) => ({ ...f, image: p })) }
|
||||||
if (path) setForm((f) => ({ ...f, exe: path }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickImage = async () => {
|
|
||||||
const path = await window.launcher.pickImage()
|
|
||||||
if (path) setForm((f) => ({ ...f, image: path }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) { setError('Введите название'); return }
|
if (!form.name.trim()) { setError('Введите название'); return }
|
||||||
if (!form.exe.trim()) { setError('Выберите исполняемый файл'); return }
|
if (!form.exe.trim()) { setError('Выберите исполняемый файл'); return }
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
exe: form.exe.trim(),
|
exe: form.exe.trim(),
|
||||||
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
|
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
|
||||||
image: form.image.trim(),
|
image: form.image.trim(),
|
||||||
category: form.category,
|
category: form.category,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'edit' && editId) {
|
if (kind === 'game') {
|
||||||
await onUpdate({ ...payload, id: editId, source: 'custom' })
|
if (mode === 'edit' && editId) await onUpdate({ ...payload, id: editId, source: 'custom' })
|
||||||
|
else await onAdd(payload)
|
||||||
} else {
|
} else {
|
||||||
await onAdd(payload)
|
if (mode === 'edit' && editId) await onUpdateApp({ ...payload, id: editId, source: 'app' })
|
||||||
|
else await onAddApp(payload)
|
||||||
}
|
}
|
||||||
setMode('list')
|
setMode('list')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -89,92 +108,129 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemove = async (id: string, name: string) => {
|
const handleRemove = async (id: string, name: string, isApp: boolean) => {
|
||||||
if (!confirm(`Удалить «${name}»?`)) return
|
if (!confirm(`Удалить «${name}»?`)) return
|
||||||
await onRemove(id)
|
if (isApp) await onRemoveApp(id)
|
||||||
|
else await onRemove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categories = kind === 'game' ? GAME_CATEGORIES : APP_CATEGORIES
|
||||||
|
|
||||||
|
const itemTitle = kind === 'game' ? 'игру' : 'приложение'
|
||||||
|
|
||||||
|
const EntryRow = ({ entry, isApp }: { entry: CustomGame | AppEntry; isApp: boolean }) => (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-bg rounded-lg border border-border">
|
||||||
|
{entry.image ? (
|
||||||
|
<img
|
||||||
|
src={entry.image.startsWith('http') ? entry.image : `file://${entry.image.replace(/\\/g, '/')}`}
|
||||||
|
alt={entry.name}
|
||||||
|
className="w-12 h-7 object-cover rounded shrink-0"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-7 bg-border rounded shrink-0 flex items-center justify-center">
|
||||||
|
{isApp ? <AppWindow size={12} className="text-muted" /> : <Gamepad2 size={12} className="text-muted" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-text text-sm font-medium truncate">{entry.name}</p>
|
||||||
|
<p className="text-muted text-xs truncate">{entry.exe}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted/70 px-2 py-0.5 bg-border rounded-full shrink-0">
|
||||||
|
{entry.category}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => startEdit(entry)} className="text-muted hover:text-accent transition-colors p-1">
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleRemove(entry.id, entry.name, isApp)} className="text-muted hover:text-red-400 transition-colors p-1">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div
|
||||||
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl">
|
className={`fixed inset-0 z-50 flex justify-end transition-all duration-280 ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
|
||||||
|
style={{ background: visible ? 'rgba(0,0,0,0.7)' : 'transparent', backdropFilter: visible ? 'blur(4px)' : 'none', transition: 'all 0.28s ease' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col w-full max-w-xl h-full bg-card border-l border-border shadow-2xl transition-transform duration-280 ease-spring ${visible ? 'translate-x-0' : 'translate-x-full'}`}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/80 backdrop-blur-xs shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-text font-semibold text-lg">
|
<h2 className="text-text font-semibold text-lg">
|
||||||
{mode === 'list' ? 'Управление играми' : mode === 'add' ? 'Добавить игру' : 'Редактировать игру'}
|
{mode === 'list' ? 'Управление' : mode === 'add' ? `Добавить ${itemTitle}` : `Редактировать ${itemTitle}`}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted text-xs mt-0.5">Admin Panel · Ctrl+Alt+A</p>
|
<p className="text-muted text-xs mt-0.5">Admin · Ctrl+Alt+A · Esc — закрыть</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-muted hover:text-text transition-colors p-1">
|
<button onClick={handleClose} className="p-2 text-muted hover:text-text transition-colors rounded-lg hover:bg-cardHover">
|
||||||
<X size={20} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{mode === 'list' ? (
|
{mode === 'list' ? (
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Custom games list */}
|
{/* Custom games section */}
|
||||||
{customGames.length === 0 ? (
|
<div>
|
||||||
<p className="text-muted text-sm text-center py-8">Нет добавленных игр</p>
|
<div className="flex items-center justify-between mb-3">
|
||||||
) : (
|
<h3 className="text-text text-sm font-semibold flex items-center gap-2">
|
||||||
<div className="space-y-2 mb-4">
|
<Gamepad2 size={14} className="text-accent" /> Игры ({customGames.length})
|
||||||
{customGames.map((game) => (
|
</h3>
|
||||||
<div
|
<button onClick={() => startAdd('game')} className="flex items-center gap-1.5 px-3 py-1.5 bg-accent hover:bg-accentHover text-white rounded-lg text-xs font-medium transition-colors">
|
||||||
key={game.id}
|
<Plus size={13} /> Добавить
|
||||||
className="flex items-center gap-3 p-3 bg-bg rounded-lg border border-border"
|
</button>
|
||||||
|
</div>
|
||||||
|
{customGames.length === 0
|
||||||
|
? <p className="text-muted text-xs text-center py-4 border border-dashed border-border rounded-lg">Нет добавленных игр</p>
|
||||||
|
: <div className="space-y-2">{customGames.map((g) => <EntryRow key={g.id} entry={g} isApp={false} />)}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-text text-sm font-semibold flex items-center gap-2">
|
||||||
|
<AppWindow size={14} className="text-blue-400" /> Приложения ({appEntries.length})
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => startAdd('app')} className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-xs font-medium transition-colors">
|
||||||
|
<Plus size={13} /> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{appEntries.length === 0
|
||||||
|
? <p className="text-muted text-xs text-center py-4 border border-dashed border-border rounded-lg">Нет добавленных приложений</p>
|
||||||
|
: <div className="space-y-2">{appEntries.map((a) => <EntryRow key={a.id} entry={a} isApp={true} />)}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
Steam ({games.filter((g) => g.source === 'steam').length}) и Epic ({games.filter((g) => g.source === 'epic').length}) определяются автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Form */
|
||||||
|
<div className="p-6 space-y-4 animate-slide-in-up">
|
||||||
|
{/* Kind toggle (only when adding) */}
|
||||||
|
{mode === 'add' && (
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['game', 'app'] as EntryKind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => { setKind(k); setForm((f) => ({ ...f, category: k === 'game' ? 'Other' : 'Другое' })) }}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium transition-colors ${
|
||||||
|
kind === k ? 'bg-accent text-white' : 'text-muted hover:text-text'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{game.image ? (
|
{k === 'game' ? <><Gamepad2 size={14} /> Игра</> : <><AppWindow size={14} /> Приложение</>}
|
||||||
<img
|
</button>
|
||||||
src={game.image.startsWith('http') ? game.image : `file://${game.image.replace(/\\/g, '/')}`}
|
|
||||||
alt={game.name}
|
|
||||||
className="w-14 h-7 object-cover rounded shrink-0"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-14 h-7 bg-border rounded shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-text text-sm font-medium truncate">{game.name}</p>
|
|
||||||
<p className="text-muted text-xs truncate">{game.exe}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted/70 px-2 py-0.5 bg-border rounded shrink-0">
|
|
||||||
{game.category}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => startEdit(game)}
|
|
||||||
className="text-muted hover:text-accent transition-colors p-1 shrink-0"
|
|
||||||
>
|
|
||||||
<Edit2 size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(game.id, game.name)}
|
|
||||||
className="text-muted hover:text-red-400 transition-colors p-1 shrink-0"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto-detected info */}
|
|
||||||
<p className="text-muted text-xs mb-4">
|
|
||||||
Steam ({games.filter((g) => g.source === 'steam').length}) и Epic (
|
|
||||||
{games.filter((g) => g.source === 'epic').length}) определяются автоматически.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={startAdd}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accentHover text-white rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
Добавить игру
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Add / Edit form */
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-text text-sm mb-1 block">Название *</label>
|
<label className="text-text text-sm mb-1 block">Название *</label>
|
||||||
@@ -182,7 +238,7 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
placeholder="Counter-Strike 2"
|
placeholder={kind === 'game' ? 'Counter-Strike 2' : 'Discord'}
|
||||||
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,22 +251,18 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.exe}
|
value={form.exe}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, exe: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, exe: e.target.value }))}
|
||||||
placeholder="C:\Games\game.exe"
|
placeholder="C:\Program Files\App\app.exe"
|
||||||
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={pickExe} className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors" title="Выбрать">
|
||||||
onClick={pickExe}
|
<FolderOpen size={15} />
|
||||||
className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors"
|
|
||||||
title="Выбрать файл"
|
|
||||||
>
|
|
||||||
<FolderOpen size={16} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Args */}
|
{/* Args */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-text text-sm mb-1 block">Аргументы запуска <span className="text-muted">(необязательно)</span></label>
|
<label className="text-text text-sm mb-1 block">Аргументы <span className="text-muted">(необязательно)</span></label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.args}
|
value={form.args}
|
||||||
@@ -222,28 +274,24 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-text text-sm mb-1 block">Обложка <span className="text-muted">(необязательно)</span></label>
|
<label className="text-text text-sm mb-1 block">Изображение <span className="text-muted">(необязательно)</span></label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.image}
|
value={form.image}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, image: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, image: e.target.value }))}
|
||||||
placeholder="C:\Games\cover.jpg или https://..."
|
placeholder="C:\cover.jpg или https://..."
|
||||||
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
className="flex-1 px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm placeholder-muted focus:outline-none focus:border-accent transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={pickImage} className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors" title="Выбрать">
|
||||||
onClick={pickImage}
|
<Image size={15} />
|
||||||
className="px-3 py-2 bg-card border border-border rounded-lg text-muted hover:text-text hover:border-accent transition-colors"
|
|
||||||
title="Выбрать изображение"
|
|
||||||
>
|
|
||||||
<Image size={16} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{form.image && (
|
{form.image && (
|
||||||
<img
|
<img
|
||||||
src={form.image.startsWith('http') ? form.image : `file://${form.image.replace(/\\/g, '/')}`}
|
src={form.image.startsWith('http') ? form.image : `file://${form.image.replace(/\\/g, '/')}`}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
className="mt-2 h-16 rounded object-cover border border-border"
|
className="mt-2 h-14 rounded object-cover border border-border"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -257,9 +305,7 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||||
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm focus:outline-none focus:border-accent transition-colors"
|
className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-text text-sm focus:outline-none focus:border-accent transition-colors"
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -268,19 +314,16 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer for form modes */}
|
{/* Footer */}
|
||||||
{mode !== 'list' && (
|
{mode !== 'list' && (
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
||||||
<button
|
<button onClick={() => { setMode('list'); setError(null) }} className="px-4 py-2 text-muted hover:text-text text-sm transition-colors">
|
||||||
onClick={() => { setMode('list'); setError(null) }}
|
|
||||||
className="px-4 py-2 text-muted hover:text-text text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-5 py-2 bg-accent hover:bg-accentHover disabled:bg-accent/50 text-white rounded-lg text-sm font-medium transition-colors"
|
className="px-5 py-2 bg-accent hover:bg-accentHover disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? 'Сохранение...' : mode === 'add' ? 'Добавить' : 'Сохранить'}
|
{saving ? 'Сохранение...' : mode === 'add' ? 'Добавить' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import type { Category } from '../types'
|
import type { Category } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,28 +12,52 @@ interface Props {
|
|||||||
|
|
||||||
export function CategoryFilter({ active, counts, hasEpic, hasFavorites, hasRecent, onChange }: Props) {
|
export function CategoryFilter({ active, counts, hasEpic, hasFavorites, hasRecent, onChange }: Props) {
|
||||||
const tabs: { key: Category; label: string }[] = [
|
const tabs: { key: Category; label: string }[] = [
|
||||||
{ key: 'all', label: 'Все' },
|
{ key: 'all', label: 'Все' },
|
||||||
{ key: 'steam', label: 'Steam' },
|
{ key: 'steam', label: 'Steam' },
|
||||||
...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []),
|
...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []),
|
||||||
{ key: 'custom', label: 'Добавленные' },
|
{ key: 'custom', label: 'Игры' },
|
||||||
|
{ key: 'app', label: 'Приложения' },
|
||||||
...(hasFavorites ? [{ key: 'favorites' as Category, label: '⭐ Избранное' }] : []),
|
...(hasFavorites ? [{ key: 'favorites' as Category, label: '⭐ Избранное' }] : []),
|
||||||
...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []),
|
...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [indicator, setIndicator] = useState({ left: 0, width: 0 })
|
||||||
|
const buttonRefs = useRef<Map<Category, HTMLButtonElement>>(new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = buttonRefs.current.get(active)
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!el || !container) return
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const elRect = el.getBoundingClientRect()
|
||||||
|
setIndicator({
|
||||||
|
left: elRect.left - containerRect.left,
|
||||||
|
width: elRect.width,
|
||||||
|
})
|
||||||
|
}, [active, tabs.length])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div ref={containerRef} className="relative flex gap-0.5">
|
||||||
|
{/* Sliding indicator */}
|
||||||
|
<span
|
||||||
|
className="absolute bottom-0 h-0.5 bg-accent rounded-full transition-all duration-200 ease-spring"
|
||||||
|
style={{ left: indicator.left, width: indicator.width }}
|
||||||
|
/>
|
||||||
|
|
||||||
{tabs.map(({ key, label }) => (
|
{tabs.map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
ref={(el) => { if (el) buttonRefs.current.set(key, el) }}
|
||||||
onClick={() => onChange(key)}
|
onClick={() => onChange(key)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 pb-2 rounded-t-lg text-sm font-medium transition-colors ${
|
||||||
active === key
|
active === key
|
||||||
? 'bg-accent text-white'
|
? 'text-text'
|
||||||
: 'bg-card text-muted hover:text-text hover:bg-cardHover'
|
: 'text-muted hover:text-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className={`ml-1.5 text-xs ${active === key ? 'text-white/70' : 'text-muted/70'}`}>
|
<span className={`ml-1.5 text-xs ${active === key ? 'text-accent' : 'text-muted/60'}`}>
|
||||||
{counts[key]}
|
{counts[key]}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+116
-48
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef, useCallback } from 'react'
|
||||||
import { Play, Settings, Star } from 'lucide-react'
|
import { Play, Settings, Star } from 'lucide-react'
|
||||||
import type { Game } from '../types'
|
import type { Game } from '../types'
|
||||||
|
|
||||||
const COLORS = [
|
// ── Placeholder ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GRADIENTS = [
|
||||||
['#1a1a3e', '#2d1b69'],
|
['#1a1a3e', '#2d1b69'],
|
||||||
['#1e3a5f', '#0d2137'],
|
['#1e3a5f', '#0d2137'],
|
||||||
['#2d1b2e', '#4a1942'],
|
['#2d1b2e', '#4a1942'],
|
||||||
@@ -15,13 +17,12 @@ function nameHash(name: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makePlaceholder(name: string): string {
|
function makePlaceholder(name: string): string {
|
||||||
const [c1, c2] = COLORS[nameHash(name) % COLORS.length]
|
const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length]
|
||||||
const initial = (name[0] ?? '?').toUpperCase()
|
const initial = (name[0] ?? '?').toUpperCase()
|
||||||
const safe = name.replace(/</g, '<').replace(/>/g, '>')
|
const safe = name.replace(/</g, '<').replace(/>/g, '>')
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="215" viewBox="0 0 460 215">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="215" viewBox="0 0 460 215">
|
||||||
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="${c1}"/>
|
<stop offset="0%" stop-color="${c1}"/><stop offset="100%" stop-color="${c2}"/>
|
||||||
<stop offset="100%" stop-color="${c2}"/>
|
|
||||||
</linearGradient></defs>
|
</linearGradient></defs>
|
||||||
<rect width="460" height="215" fill="url(#g)"/>
|
<rect width="460" height="215" fill="url(#g)"/>
|
||||||
<text x="230" y="125" font-size="80" fill="white" fill-opacity="0.12" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
|
<text x="230" y="125" font-size="80" fill="white" fill-opacity="0.12" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
|
||||||
@@ -30,22 +31,44 @@ function makePlaceholder(name: string): string {
|
|||||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageSrc(game: Game): string {
|
function makeAppPlaceholder(name: string): string {
|
||||||
if (game.source === 'steam') return game.headerUrl
|
const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length]
|
||||||
if (game.source === 'epic') return makePlaceholder(game.name)
|
const initial = (name[0] ?? '?').toUpperCase()
|
||||||
if (game.image) {
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||||||
if (game.image.startsWith('http')) return game.image
|
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
return `file://${game.image.replace(/\\/g, '/')}`
|
<stop offset="0%" stop-color="${c1}"/><stop offset="100%" stop-color="${c2}"/>
|
||||||
}
|
</linearGradient></defs>
|
||||||
return makePlaceholder(game.name)
|
<rect width="200" height="200" rx="24" fill="url(#g)"/>
|
||||||
|
<text x="100" y="128" font-size="90" fill="white" fill-opacity="0.9" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
|
||||||
|
</svg>`
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_BADGE: Record<string, { bg: string; text: string; label: string }> = {
|
function getImageSrc(game: Game): string {
|
||||||
steam: { bg: 'bg-[#1b2838]', text: 'text-[#c7d5e0]', label: 'STEAM' },
|
if (game.source === 'steam') return game.headerUrl
|
||||||
epic: { bg: 'bg-[#2b1c8a]/80', text: 'text-[#b0a0ff]', label: 'EPIC' },
|
if (game.source === 'epic') return makePlaceholder(game.name)
|
||||||
custom: { bg: 'bg-accent/20', text: 'text-accent', label: 'CUSTOM' },
|
if (game.source === 'app') {
|
||||||
|
if (!game.image) return makeAppPlaceholder(game.name)
|
||||||
|
if (game.image.startsWith('http')) return game.image
|
||||||
|
return `file://${game.image.replace(/\\/g, '/')}`
|
||||||
|
}
|
||||||
|
// custom
|
||||||
|
if (!game.image) return makePlaceholder(game.name)
|
||||||
|
if (game.image.startsWith('http')) return game.image
|
||||||
|
return `file://${game.image.replace(/\\/g, '/')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Badge config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BADGE: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
steam: { bg: 'bg-[#1b2838]', text: 'text-[#c7d5e0]', label: 'STEAM' },
|
||||||
|
epic: { bg: 'bg-[#2b1c8a]/80', text: 'text-[#b0a0ff]', label: 'EPIC' },
|
||||||
|
custom: { bg: 'bg-accent/20', text: 'text-accent', label: 'GAME' },
|
||||||
|
app: { bg: 'bg-blue-600/30', text: 'text-blue-300', label: 'APP' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game
|
game: Game
|
||||||
index: number
|
index: number
|
||||||
@@ -54,81 +77,126 @@ interface Props {
|
|||||||
onToggleFavorite?: (id: string) => void
|
onToggleFavorite?: (id: string) => void
|
||||||
isFavorite?: boolean
|
isFavorite?: boolean
|
||||||
adminMode?: boolean
|
adminMode?: boolean
|
||||||
|
isApp?: boolean // renders in square app-tile mode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameCard({ game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode }: Props) {
|
// ── Component ─────────────────────────────────────────────────────────────
|
||||||
const [imgError, setImgError] = useState(false)
|
|
||||||
|
export function GameCard({
|
||||||
|
game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode, isApp,
|
||||||
|
}: Props) {
|
||||||
|
const [imgError, setImgError] = useState(false)
|
||||||
const [launching, setLaunching] = useState(false)
|
const [launching, setLaunching] = useState(false)
|
||||||
|
const [favBounce, setFavBounce] = useState(false)
|
||||||
|
const [tilt, setTilt] = useState({ x: 0, y: 0 })
|
||||||
|
const [ripple, setRipple] = useState(false)
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 3D tilt on mouse move
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
const rect = cardRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const x = (e.clientX - rect.left) / rect.width - 0.5
|
||||||
|
const y = (e.clientY - rect.top) / rect.height - 0.5
|
||||||
|
setTilt({ x: y * 6, y: -x * 6 })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseLeave = () => setTilt({ x: 0, y: 0 })
|
||||||
|
|
||||||
const handleLaunch = async () => {
|
const handleLaunch = async () => {
|
||||||
|
setRipple(true)
|
||||||
|
setTimeout(() => setRipple(false), 500)
|
||||||
setLaunching(true)
|
setLaunching(true)
|
||||||
try {
|
try { await onLaunch(game.id) }
|
||||||
await onLaunch(game.id)
|
finally { setTimeout(() => setLaunching(false), 2000) }
|
||||||
} finally {
|
|
||||||
setTimeout(() => setLaunching(false), 2000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const badge = SOURCE_BADGE[game.source]
|
const handleFavorite = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setFavBounce(true)
|
||||||
|
setTimeout(() => setFavBounce(false), 350)
|
||||||
|
onToggleFavorite?.(game.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = BADGE[game.source] ?? BADGE.custom
|
||||||
|
const imgSrc = imgError ? (isApp ? makeAppPlaceholder(game.name) : makePlaceholder(game.name)) : getImageSrc(game)
|
||||||
|
const aspect = isApp ? 'aspect-square' : 'aspect-[460/215]'
|
||||||
|
const playLabel = isApp ? 'Открыть' : 'Играть'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative rounded-lg overflow-hidden bg-card cursor-pointer select-none transition-transform duration-200 hover:scale-105 hover:shadow-2xl hover:shadow-black/60 animate-fade-in"
|
ref={cardRef}
|
||||||
style={{ animationDelay: `${Math.min(index * 25, 400)}ms` }}
|
className="group relative rounded-xl overflow-hidden bg-card cursor-pointer select-none animate-fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${Math.min(index * 22, 350)}ms`,
|
||||||
|
transform: `perspective(800px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg) scale(${tilt.x || tilt.y ? 1.03 : 1})`,
|
||||||
|
transition: 'transform 0.15s ease-out, box-shadow 0.2s ease-out',
|
||||||
|
boxShadow: (tilt.x || tilt.y)
|
||||||
|
? '0 16px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--accent)20'
|
||||||
|
: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
{/* Cover image */}
|
{/* Cover */}
|
||||||
<div className="aspect-[460/215] w-full overflow-hidden">
|
<div className={`${aspect} w-full overflow-hidden`}>
|
||||||
<img
|
<img
|
||||||
src={imgError ? makePlaceholder(game.name) : getImageSrc(game)}
|
src={imgSrc}
|
||||||
alt={game.name}
|
alt={game.name}
|
||||||
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-75"
|
className="w-full h-full object-cover transition-all duration-200 group-hover:brightness-70"
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source badge */}
|
{/* Badge */}
|
||||||
<span className={`absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-semibold rounded select-none ${badge.bg} ${badge.text}`}>
|
<span className={`absolute top-2 left-2 px-2 py-0.5 text-[10px] font-semibold rounded-full select-none ${badge.bg} ${badge.text}`}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Favorite button */}
|
{/* Favorite */}
|
||||||
{!adminMode && onToggleFavorite && (
|
{!adminMode && onToggleFavorite && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleFavorite(game.id) }}
|
onClick={handleFavorite}
|
||||||
className={`absolute top-2 right-2 p-1 rounded bg-black/60 transition-all duration-150 ${
|
className={`absolute top-2 right-2 p-1.5 rounded-full bg-black/60 transition-all duration-150 ${
|
||||||
isFavorite
|
isFavorite
|
||||||
? 'text-yellow-400 opacity-100'
|
? 'text-yellow-400 opacity-100'
|
||||||
: 'text-white/50 opacity-0 group-hover:opacity-100'
|
: 'text-white/40 opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
} ${favBounce ? 'animate-bounce-star' : ''}`}
|
||||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||||
>
|
>
|
||||||
<Star size={13} fill={isFavorite ? 'currentColor' : 'none'} />
|
<Star size={12} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Admin edit button */}
|
{/* Admin edit */}
|
||||||
{adminMode && onEdit && (
|
{adminMode && onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(game) }}
|
onClick={(e) => { e.stopPropagation(); onEdit(game) }}
|
||||||
className="absolute top-2 right-2 p-1 rounded bg-black/60 text-muted hover:text-text transition-colors"
|
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-muted hover:text-text transition-colors"
|
||||||
>
|
>
|
||||||
<Settings size={14} />
|
<Settings size={13} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover overlay: play button */}
|
{/* Hover overlay */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
<div className="absolute inset-0 flex flex-col justify-end p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gradient-to-t from-black/80 via-black/20 to-transparent">
|
||||||
<p className="text-white font-semibold text-sm leading-tight mb-2 drop-shadow-lg line-clamp-2">
|
<p className="text-white font-semibold text-sm leading-tight mb-2 drop-shadow-lg line-clamp-2">
|
||||||
{game.name}
|
{game.name}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleLaunch}
|
onClick={handleLaunch}
|
||||||
disabled={launching}
|
disabled={launching}
|
||||||
className="flex items-center justify-center gap-2 w-full py-2 rounded bg-accent hover:bg-accentHover disabled:bg-accent/50 text-white font-semibold text-sm transition-colors"
|
className="relative overflow-hidden flex items-center justify-center gap-2 w-full py-2 rounded-lg bg-gradient-to-r from-accent to-accentHover hover:brightness-110 disabled:opacity-50 text-white font-semibold text-sm transition-all"
|
||||||
>
|
>
|
||||||
<Play size={14} fill="white" />
|
{/* Ripple */}
|
||||||
{launching ? 'Запуск...' : 'Играть'}
|
{ripple && (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="w-4 h-4 rounded-full bg-white/30 animate-ripple" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Play size={13} fill="white" />
|
||||||
|
{launching ? 'Запуск...' : playLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import { Gamepad2 } from 'lucide-react'
|
import { Gamepad2 } from 'lucide-react'
|
||||||
import { GameCard } from './GameCard'
|
import { GameCard } from './GameCard'
|
||||||
import type { Game } from '../types'
|
import type { Game } from '../types'
|
||||||
@@ -10,23 +11,39 @@ interface Props {
|
|||||||
onEdit: (game: Game) => void
|
onEdit: (game: Game) => void
|
||||||
onToggleFavorite: (id: string) => void
|
onToggleFavorite: (id: string) => void
|
||||||
adminMode: boolean
|
adminMode: boolean
|
||||||
|
category: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonCard() {
|
function SkeletonCard({ square }: { square?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg overflow-hidden bg-card">
|
<div className="rounded-xl overflow-hidden bg-card">
|
||||||
<div className="aspect-[460/215] w-full bg-cardHover animate-shimmer" />
|
<div className={`w-full bg-cardHover animate-shimmer ${square ? 'aspect-square' : 'aspect-[460/215]'}`} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, adminMode }: Props) {
|
export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggleFavorite, adminMode, category }: Props) {
|
||||||
|
const isApps = category === 'app'
|
||||||
|
const gridCols = isApps
|
||||||
|
? 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8'
|
||||||
|
: 'grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||||
|
|
||||||
|
// Key changes when category switches to re-trigger card animations
|
||||||
|
const [renderKey, setRenderKey] = useState(0)
|
||||||
|
const prevCat = useRef(category)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevCat.current !== category) {
|
||||||
|
prevCat.current = category
|
||||||
|
setRenderKey((k) => k + 1)
|
||||||
|
}
|
||||||
|
}, [category])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
<div className={`grid ${gridCols} gap-3`}>
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<SkeletonCard key={i} />
|
<SkeletonCard key={i} square={isApps} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,8 +55,12 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3 text-muted">
|
<div className="flex flex-col items-center gap-3 text-muted">
|
||||||
<Gamepad2 size={48} strokeWidth={1} />
|
<Gamepad2 size={48} strokeWidth={1} />
|
||||||
<p className="text-base">Игры не найдены</p>
|
<p className="text-base">Ничего не найдено</p>
|
||||||
<p className="text-sm text-muted/60">Убедитесь что Steam/Epic установлены или добавьте игры вручную</p>
|
<p className="text-sm text-muted/60">
|
||||||
|
{category === 'app'
|
||||||
|
? 'Добавьте приложения через панель администратора'
|
||||||
|
: 'Убедитесь что Steam/Epic установлены или добавьте игры вручную'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -47,7 +68,7 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
<div key={renderKey} className={`grid ${gridCols} gap-3`}>
|
||||||
{games.map((game, i) => (
|
{games.map((game, i) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={game.id}
|
key={game.id}
|
||||||
@@ -58,6 +79,7 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
isFavorite={favorites.includes(game.id)}
|
isFavorite={favorites.includes(game.id)}
|
||||||
adminMode={adminMode}
|
adminMode={adminMode}
|
||||||
|
isApp={isApps || game.source === 'app'}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Sun, Moon } from 'lucide-react'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [light, setLight] = useState(() => {
|
||||||
|
return localStorage.getItem('theme') === 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (light) {
|
||||||
|
document.documentElement.classList.add('light')
|
||||||
|
localStorage.setItem('theme', 'light')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('light')
|
||||||
|
localStorage.setItem('theme', 'dark')
|
||||||
|
}
|
||||||
|
}, [light])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setLight((l) => !l)}
|
||||||
|
className="p-2 text-muted hover:text-text transition-colors"
|
||||||
|
title={light ? 'Тёмная тема' : 'Светлая тема'}
|
||||||
|
>
|
||||||
|
{light ? <Moon size={17} /> : <Sun size={17} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+19
-13
@@ -1,28 +1,36 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import type { CustomGame, Game } from '../types'
|
import type { AppEntry, CustomGame, Game } from '../types'
|
||||||
|
|
||||||
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[] }>
|
||||||
addCustomGame: (game: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
// Custom games
|
||||||
removeGame: (id: string) => Promise<{ ok: boolean }>
|
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
|
||||||
updateGame: (game: CustomGame) => Promise<{ ok: boolean }>
|
removeGame: (id: string) => Promise<{ ok: boolean }>
|
||||||
pickExe: () => Promise<string | null>
|
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>
|
pickImage: () => Promise<string | null>
|
||||||
onAdminOpen: (cb: () => void) => () => void
|
// Favorites / recent
|
||||||
getFavorites: () => Promise<string[]>
|
getFavorites: () => Promise<string[]>
|
||||||
toggleFavorite: (id: string) => Promise<string[]>
|
toggleFavorite: (id: string) => Promise<string[]>
|
||||||
getRecent: () => Promise<string[]>
|
getRecent: () => Promise<string[]>
|
||||||
|
// Events
|
||||||
|
onAdminOpen: (cb: () => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGames() {
|
export function useGames() {
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [games, setGames] = useState<Game[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -37,9 +45,7 @@ export function useGames() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { load() }, [load])
|
||||||
load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
return { games, loading, error, reload: load }
|
return { games, loading, error, reload: load }
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-21
@@ -2,35 +2,52 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Theme variables ──────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--card: #1a1a2e;
|
||||||
|
--cardHover: #16213e;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--accentH: #16a34a;
|
||||||
|
--surface: #0f3460;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: #1e293b;
|
||||||
|
--scroll: #1e293b;
|
||||||
|
--scrollH: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
--bg: #f1f5f9;
|
||||||
|
--card: #ffffff;
|
||||||
|
--cardHover: #f8fafc;
|
||||||
|
--accent: #16a34a;
|
||||||
|
--accentH: #15803d;
|
||||||
|
--surface: #dbeafe;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--scroll: #cbd5e1;
|
||||||
|
--scrollH: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────── */
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #0f0f0f;
|
background-color: var(--bg);
|
||||||
color: #e2e8f0;
|
color: var(--text);
|
||||||
font-family: -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar { width: 6px; }
|
||||||
width: 6px;
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
}
|
::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollH); }
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #0f0f0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #334155;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -26,9 +26,19 @@ export interface CustomGame {
|
|||||||
source: 'custom'
|
source: 'custom'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Game = SteamGame | EpicGame | CustomGame
|
export interface AppEntry {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
exe: string
|
||||||
|
args: string[]
|
||||||
|
image: string
|
||||||
|
category: string
|
||||||
|
source: 'app'
|
||||||
|
}
|
||||||
|
|
||||||
export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'favorites' | 'recent'
|
export type Game = SteamGame | EpicGame | CustomGame | AppEntry
|
||||||
|
|
||||||
|
export type Category = 'all' | 'steam' | 'epic' | 'custom' | 'app' | 'favorites' | 'recent'
|
||||||
|
|
||||||
export type SortOrder = 'name-asc' | 'name-desc'
|
export type SortOrder = 'name-asc' | 'name-desc'
|
||||||
|
|
||||||
|
|||||||
+45
-13
@@ -2,35 +2,67 @@ import type { Config } from 'tailwindcss'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{ts,tsx}', './index.html'],
|
content: ['./src/**/*.{ts,tsx}', './index.html'],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#0f0f0f',
|
bg: 'var(--bg)',
|
||||||
card: '#1a1a2e',
|
card: 'var(--card)',
|
||||||
cardHover: '#16213e',
|
cardHover: 'var(--cardHover)',
|
||||||
accent: '#22c55e',
|
accent: 'var(--accent)',
|
||||||
accentHover: '#16a34a',
|
accentHover: 'var(--accentH)',
|
||||||
surface: '#0f3460',
|
surface: 'var(--surface)',
|
||||||
text: '#e2e8f0',
|
text: 'var(--text)',
|
||||||
muted: '#64748b',
|
muted: 'var(--muted)',
|
||||||
border: '#1e293b',
|
border: 'var(--border)',
|
||||||
},
|
},
|
||||||
aspectRatio: {
|
aspectRatio: {
|
||||||
steam: '460 / 215',
|
steam: '460 / 215',
|
||||||
|
app: '1 / 1',
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.22s ease-out both',
|
'fade-in': 'fadeIn 0.22s ease-out both',
|
||||||
shimmer: 'shimmer 1.4s ease-in-out infinite',
|
'scale-in': 'scaleIn 0.18s ease-out both',
|
||||||
|
'slide-in-right':'slideInRight 0.28s cubic-bezier(0.16,1,0.3,1) both',
|
||||||
|
'slide-in-up': 'slideInUp 0.22s ease-out both',
|
||||||
|
shimmer: 'shimmer 1.4s ease-in-out infinite',
|
||||||
|
'bounce-star': 'bounceStar 0.35s ease-out',
|
||||||
|
ripple: 'ripple 0.5s ease-out',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||||
to: { opacity: '1', transform: 'translateY(0)' },
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
from: { opacity: '0', transform: 'scale(0.93)' },
|
||||||
|
to: { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
slideInRight: {
|
||||||
|
from: { transform: 'translateX(100%)' },
|
||||||
|
to: { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
slideInUp: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(16px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
},
|
},
|
||||||
shimmer: {
|
shimmer: {
|
||||||
'0%, 100%': { opacity: '0.4' },
|
'0%, 100%': { opacity: '0.4' },
|
||||||
'50%': { opacity: '0.8' },
|
'50%': { opacity: '0.8' },
|
||||||
},
|
},
|
||||||
|
bounceStar: {
|
||||||
|
'0%': { transform: 'scale(1)' },
|
||||||
|
'40%': { transform: 'scale(1.45)' },
|
||||||
|
'100%': { transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
ripple: {
|
||||||
|
'0%': { transform: 'scale(0)', opacity: '0.4' },
|
||||||
|
'100%': { transform: 'scale(2.5)', opacity: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backdropBlur: { xs: '4px' },
|
||||||
|
transitionTimingFunction: {
|
||||||
|
spring: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user