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:
2026-06-02 12:59:37 +00:00
parent 35d46f532a
commit 35c61c2496
15 changed files with 710 additions and 392 deletions
+77 -19
View File
@@ -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)))
} }
+36 -55
View File
@@ -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]
}) })
// Favorites / recent
ipcMain.handle('favorites:get', () => getFavorites()) ipcMain.handle('favorites:get', () => getFavorites())
ipcMain.handle('favorites:toggle', (_event, 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 ────────────────────────────────────────────────────────
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()
}) })
+20 -7
View File
@@ -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 = {
// Games
getGames: () => ipcRenderer.invoke('games:get-all'), 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),
// Custom games (admin)
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: (game: CustomGame) => ipcRenderer.invoke('admin:update-game', game), 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>, 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
+2 -2
View File
@@ -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
View File
@@ -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",
+52 -37
View File
@@ -5,10 +5,12 @@ 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 [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')
@@ -17,17 +19,23 @@ export default function App() {
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)
window.launcher.getRecent().then(setRecent) window.launcher.getRecent().then(setRecent)
}, []) }, [])
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> => {
@@ -37,6 +45,7 @@ export default function App() {
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,
} }
@@ -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 = () => {
setAdminOpen(false)
setAdminMode(false)
}
const hasEpic = counts.epic > 0 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)' }}
>
{loading ? 'Загрузка...' : `${filtered.length} из ${games.length} записей`}
{adminMode && <span className="ml-3 text-accent"> Режим администратора</span>} {adminMode && <span className="ml-3 text-accent"> Режим администратора</span>}
</p>
</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>
+165 -122
View File
@@ -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,27 +16,52 @@ 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,
export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) { onAdd, onRemove, onUpdate,
onAddApp, onRemoveApp, onUpdateApp,
}: Props) {
const [mode, setMode] = useState<Mode>('list') const [mode, setMode] = useState<Mode>('list')
const [kind, setKind] = useState<EntryKind>('game')
const [form, setForm] = useState<GameFormData>(EMPTY_FORM) const [form, setForm] = useState<GameFormData>(EMPTY_FORM)
const [editId, setEditId] = useState<string | null>(null) const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [visible, setVisible] = useState(false)
// Animate in
useEffect(() => { requestAnimationFrame(() => setVisible(true)) }, [])
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const handleClose = () => {
setVisible(false)
setTimeout(onClose, 280)
}
const customGames = games.filter((g): g is CustomGame => g.source === 'custom') const 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,
@@ -46,20 +69,14 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
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 }
@@ -76,10 +93,12 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props)
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"
>
{game.image ? (
<img
src={game.image.startsWith('http') ? game.image : `file://${game.image.replace(/\\/g, '/')}`}
alt={game.name}
className="w-14 h-7 object-cover rounded shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="w-14 h-7 bg-border rounded shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-text text-sm font-medium truncate">{game.name}</p>
<p className="text-muted text-xs truncate">{game.exe}</p>
</div>
<span className="text-xs text-muted/70 px-2 py-0.5 bg-border rounded shrink-0">
{game.category}
</span>
<button
onClick={() => startEdit(game)}
className="text-muted hover:text-accent transition-colors p-1 shrink-0"
>
<Edit2 size={15} />
</button>
<button
onClick={() => handleRemove(game.id, game.name)}
className="text-muted hover:text-red-400 transition-colors p-1 shrink-0"
>
<Trash2 size={15} />
</button> </button>
</div> </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'
}`}
>
{k === 'game' ? <><Gamepad2 size={14} /> Игра</> : <><AppWindow size={14} /> Приложение</>}
</button>
))} ))}
</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>
+31 -6
View File
@@ -1,3 +1,4 @@
import { useRef, useEffect, useState } from 'react'
import type { Category } from '../types' import type { Category } from '../types'
interface Props { interface Props {
@@ -14,25 +15,49 @@ export function CategoryFilter({ active, counts, hasEpic, hasFavorites, hasRecen
{ 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>
+106 -38
View File
@@ -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, '&lt;').replace(/>/g, '&gt;') const safe = name.replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 makeAppPlaceholder(name: string): string {
const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length]
const initial = (name[0] ?? '?').toUpperCase()
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${c1}"/><stop offset="100%" stop-color="${c2}"/>
</linearGradient></defs>
<rect width="200" height="200" rx="24" fill="url(#g)"/>
<text x="100" y="128" font-size="90" fill="white" fill-opacity="0.9" text-anchor="middle" font-family="sans-serif" font-weight="bold">${initial}</text>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
function getImageSrc(game: Game): string { function getImageSrc(game: Game): string {
if (game.source === 'steam') return game.headerUrl if (game.source === 'steam') return game.headerUrl
if (game.source === 'epic') return makePlaceholder(game.name) if (game.source === 'epic') return makePlaceholder(game.name)
if (game.image) { if (game.source === 'app') {
if (!game.image) return makeAppPlaceholder(game.name)
if (game.image.startsWith('http')) return game.image if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}` return `file://${game.image.replace(/\\/g, '/')}`
} }
return makePlaceholder(game.name) // custom
if (!game.image) return makePlaceholder(game.name)
if (game.image.startsWith('http')) return game.image
return `file://${game.image.replace(/\\/g, '/')}`
} }
const SOURCE_BADGE: Record<string, { bg: string; text: string; label: string }> = { // ── Badge config ──────────────────────────────────────────────────────────
const BADGE: Record<string, { bg: string; text: string; label: string }> = {
steam: { bg: 'bg-[#1b2838]', text: 'text-[#c7d5e0]', label: 'STEAM' }, steam: { bg: 'bg-[#1b2838]', text: 'text-[#c7d5e0]', label: 'STEAM' },
epic: { bg: 'bg-[#2b1c8a]/80', text: 'text-[#b0a0ff]', label: 'EPIC' }, epic: { bg: 'bg-[#2b1c8a]/80', text: 'text-[#b0a0ff]', label: 'EPIC' },
custom: { bg: 'bg-accent/20', text: 'text-accent', label: 'CUSTOM' }, 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 ─────────────────────────────────────────────────────────────
export function GameCard({
game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode, isApp,
}: Props) {
const [imgError, setImgError] = useState(false) 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>
+31 -9
View File
@@ -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>
+28
View File
@@ -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>
)
}
+13 -7
View File
@@ -1,20 +1,28 @@
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
addCustomGame: (g: Omit<CustomGame, 'id' | 'source'>) => Promise<CustomGame>
removeGame: (id: string) => Promise<{ ok: boolean }> removeGame: (id: string) => Promise<{ ok: boolean }>
updateGame: (game: CustomGame) => Promise<{ ok: boolean }> updateGame: (g: CustomGame) => Promise<{ ok: boolean }>
// App entries
addApp: (a: Omit<AppEntry, 'id' | 'source'>) => Promise<AppEntry>
removeApp: (id: string) => Promise<{ ok: boolean }>
updateApp: (a: AppEntry) => Promise<{ ok: boolean }>
// Dialogs
pickExe: () => Promise<string | null> 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
} }
} }
} }
@@ -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
View File
@@ -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
View File
@@ -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'
+41 -9
View File
@@ -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',
'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', 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)',
}, },
}, },
}, },