From 35c61c2496024ac31ac36a868ead20f63a957fcd Mon Sep 17 00:00:00 2001 From: houseassassin Date: Tue, 2 Jun 2026 12:59:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20Apps=20tab,=20ligh?= =?UTF-8?q?t/dark=20theme,=20visual=20overhaul,=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- electron/config.ts | 96 +++++++-- electron/main.ts | 99 ++++------ electron/preload.ts | 33 +++- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 113 ++++++----- src/components/AdminPanel.tsx | 315 +++++++++++++++++------------- src/components/CategoryFilter.tsx | 45 ++++- src/components/GameCard.tsx | 164 +++++++++++----- src/components/GameGrid.tsx | 40 +++- src/components/ThemeToggle.tsx | 28 +++ src/hooks/useGames.ts | 32 +-- src/index.css | 59 ++++-- src/types.ts | 14 +- tailwind.config.ts | 58 ++++-- 15 files changed, 710 insertions(+), 392 deletions(-) create mode 100644 src/components/ThemeToggle.tsx diff --git a/electron/config.ts b/electron/config.ts index f5f7bea..0b9adb2 100644 --- a/electron/config.ts +++ b/electron/config.ts @@ -1,6 +1,7 @@ import { app } from 'electron' 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 { id: string @@ -12,36 +13,93 @@ export interface CustomGame { source: 'custom' } -interface GamesConfig { - games: CustomGame[] +export interface AppEntry { + 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') - return join(dir, 'games.json') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + return join(dir, filename) } +function readJsonList(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(filename: string, items: T[]): void { + writeFileSync(getUserDataPath(filename), JSON.stringify({ items }, null, 2), 'utf-8') +} + +// ── Custom games ───────────────────────────────────────────────────────── + 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 [] - try { - const raw = readFileSync(path, 'utf-8') - const data = JSON.parse(raw) as GamesConfig - return Array.isArray(data.games) ? data.games : [] + const data = JSON.parse(readFileSync(path, 'utf-8')) + if (Array.isArray(data?.games)) return data.games // legacy + if (Array.isArray(data?.items)) return data.items // new + return [] } catch { return [] } } export function writeCustomGames(games: CustomGame[]): void { - const path = getConfigPath() - const dir = path.substring(0, path.lastIndexOf(require('path').sep)) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - const data: GamesConfig = { games } - writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8') + writeFileSync(getUserDataPath('games.json'), JSON.stringify({ items: games }, null, 2), 'utf-8') +} + +export function addCustomGame(data: Omit): CustomGame { + const games = readCustomGames() + const newGame: CustomGame = { ...data, id: uuidv4(), source: 'custom' } + writeCustomGames([...games, newGame]) + return newGame +} + +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('apps.json') +} + +export function writeApps(apps: AppEntry[]): void { + writeJsonList('apps.json', apps) +} + +export function addApp(data: Omit): 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))) } diff --git a/electron/main.ts b/electron/main.ts index 7cbae7d..4c468e8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,9 +1,13 @@ import { app, BrowserWindow, ipcMain, dialog, shell, globalShortcut } from 'electron' import { join } from 'path' -import { v4 as uuidv4 } from 'uuid' import { detectSteamGames } from './steam' 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 { getFavorites, toggleFavorite, getRecent, addRecent } from './favorites' @@ -34,105 +38,82 @@ function createWindow(): void { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } - mainWindow.on('closed', () => { - mainWindow = null - }) + mainWindow.on('closed', () => { mainWindow = null }) } -// ── IPC Handlers ──────────────────────────────────────────────────────────── +// ── IPC ────────────────────────────────────────────────────────────────── ipcMain.handle('games:get-all', () => { - const steamGames = detectSteamGames() - const epicGames = detectEpicGames() + const steamGames = detectSteamGames() + const epicGames = detectEpicGames() const customGames = readCustomGames() - return [...steamGames, ...epicGames, ...customGames] + const apps = readApps() + return [...steamGames, ...epicGames, ...customGames, ...apps] }) ipcMain.handle('games:launch', (_event, id: string) => { if (id.startsWith('steam_')) { - const appid = id.replace('steam_', '') - launchSteamGame(appid) - const recent = addRecent(id) - return { ok: true, recent } + launchSteamGame(id.replace('steam_', '')) + return { ok: true, recent: addRecent(id) } } - if (id.startsWith('epic_')) { - const appName = id.replace('epic_', '') - launchEpicGame(appName) - const recent = addRecent(id) - return { ok: true, recent } + launchEpicGame(id.replace('epic_', '')) + return { ok: true, recent: addRecent(id) } } - const customs = readCustomGames() - const game = customs.find((g) => g.id === id) - if (!game) return { ok: false, error: 'Game not found' } + const all = [...readCustomGames(), ...readApps()] + const game = all.find((g) => g.id === id) + if (!game) return { ok: false, error: 'Not found' } try { launchExe(game.exe, game.args) - const recent = addRecent(id) - return { ok: true, recent } + return { ok: true, recent: addRecent(id) } } catch (e) { return { ok: false, error: String(e) } } }) -ipcMain.handle('admin:add-game', (_event, game: Omit) => { - const customs = readCustomGames() - const newGame: CustomGame = { - ...game, - id: uuidv4(), - source: 'custom', - args: game.args ?? [], - image: game.image ?? '', - category: game.category ?? 'Other', - } - writeCustomGames([...customs, newGame]) - return newGame -}) +// Custom games +ipcMain.handle('admin:add-game', (_e, d: Omit) => addCustomGame(d)) +ipcMain.handle('admin:remove-game', (_e, id: string) => { removeCustomGame(id); return { ok: true } }) +ipcMain.handle('admin:update-game', (_e, g: CustomGame) => { updateCustomGame(g); return { ok: true } }) -ipcMain.handle('admin:remove-game', (_event, id: string) => { - const customs = readCustomGames() - writeCustomGames(customs.filter((g) => g.id !== id)) - 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 } -}) +// App entries +ipcMain.handle('apps:add', (_e, d: Omit) => addApp(d)) +ipcMain.handle('apps:remove', (_e, id: string) => { removeApp(id); return { ok: true } }) +ipcMain.handle('apps:update', (_e, a: AppEntry) => { updateApp(a); return { ok: true } }) +// Dialogs ipcMain.handle('dialog:pick-exe', async () => { - const result = await dialog.showOpenDialog({ + const r = await dialog.showOpenDialog({ title: 'Выберите исполняемый файл', - filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd'] }], + filters: [{ name: 'Executable', extensions: ['exe', 'bat', 'cmd', 'lnk'] }], properties: ['openFile'], }) - return result.canceled ? null : result.filePaths[0] + return r.canceled ? null : r.filePaths[0] }) ipcMain.handle('dialog:pick-image', async () => { - const result = await dialog.showOpenDialog({ + const r = await dialog.showOpenDialog({ title: 'Выберите обложку', - filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }], + filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'ico'] }], properties: ['openFile'], }) - return result.canceled ? null : result.filePaths[0] + return r.canceled ? null : r.filePaths[0] }) -ipcMain.handle('favorites:get', () => getFavorites()) -ipcMain.handle('favorites:toggle', (_event, id: string) => toggleFavorite(id)) -ipcMain.handle('recent:get', () => getRecent()) +// Favorites / recent +ipcMain.handle('favorites:get', () => getFavorites()) +ipcMain.handle('favorites:toggle', (_e, id: string) => toggleFavorite(id)) +ipcMain.handle('recent:get', () => getRecent()) -// ── App lifecycle ──────────────────────────────────────────────────────────── +// ── App lifecycle ──────────────────────────────────────────────────────── app.whenReady().then(() => { createWindow() - globalShortcut.register('CommandOrControl+Alt+A', () => { mainWindow?.webContents.send('admin:open') }) - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) diff --git a/electron/preload.ts b/electron/preload.ts index fb505e9..bcc7e2f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,24 +1,37 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { CustomGame } from './config' +import type { CustomGame, AppEntry } from './config' const launcher = { - getGames: () => ipcRenderer.invoke('games:get-all'), + // Games + getGames: () => ipcRenderer.invoke('games:get-all'), launchGame: (id: string) => ipcRenderer.invoke('games:launch', id) as Promise<{ ok: boolean; error?: string; recent?: string[] }>, - addCustomGame: (game: Omit) => ipcRenderer.invoke('admin:add-game', game), - removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id), - updateGame: (game: CustomGame) => ipcRenderer.invoke('admin:update-game', game), - pickExe: () => ipcRenderer.invoke('dialog:pick-exe') as Promise, + + // Custom games (admin) + addCustomGame: (g: Omit) => ipcRenderer.invoke('admin:add-game', g), + removeGame: (id: string) => ipcRenderer.invoke('admin:remove-game', id), + updateGame: (g: CustomGame) => ipcRenderer.invoke('admin:update-game', g), + + // App entries (admin) + addApp: (a: Omit) => 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, pickImage: () => ipcRenderer.invoke('dialog:pick-image') as Promise, + + // Favorites / recent + getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise, + toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise, + getRecent: () => ipcRenderer.invoke('recent:get') as Promise, + + // Events onAdminOpen: (cb: () => void) => { ipcRenderer.on('admin:open', cb) return () => ipcRenderer.removeListener('admin:open', cb) }, - getFavorites: () => ipcRenderer.invoke('favorites:get') as Promise, - toggleFavorite: (id: string) => ipcRenderer.invoke('favorites:toggle', id) as Promise, - getRecent: () => ipcRenderer.invoke('recent:get') as Promise, } contextBridge.exposeInMainWorld('launcher', launcher) - export type LauncherAPI = typeof launcher diff --git a/package-lock.json b/package-lock.json index 1ef56b8..9868f37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "club-launcher", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "club-launcher", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@node-steam/vdf": "^2.0.1", "lucide-react": "^0.441.0", diff --git a/package.json b/package.json index db436ed..d5e68c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "club-launcher", - "version": "1.1.0", + "version": "1.2.0", "description": "Game launcher for computer club", "author": "houseassassin", "main": "out/main/main.js", diff --git a/src/App.tsx b/src/App.tsx index 9b23b12..51aefc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,17 +5,26 @@ import { GameGrid } from './components/GameGrid' import { SearchBar } from './components/SearchBar' import { CategoryFilter } from './components/CategoryFilter' 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() { const { games, loading, reload } = useGames() - const [search, setSearch] = useState('') - const [category, setCategory] = useState('all') - const [sort, setSort] = useState('name-asc') + + const [search, setSearch] = useState('') + const [category, setCategory] = useState('all') + const [sort, setSort] = useState('name-asc') const [adminOpen, setAdminOpen] = useState(false) const [adminMode, setAdminMode] = useState(false) const [favorites, setFavorites] = useState([]) - const [recent, setRecent] = useState([]) + const [recent, setRecent] = useState([]) + + // Load initial theme from localStorage + useEffect(() => { + if (localStorage.getItem('theme') === 'light') { + document.documentElement.classList.add('light') + } + }, []) useEffect(() => { window.launcher.getFavorites().then(setFavorites) @@ -23,22 +32,22 @@ export default function App() { }, []) useEffect(() => { - const unsubscribe = window.launcher.onAdminOpen(() => { + return window.launcher.onAdminOpen(() => { setAdminOpen(true) setAdminMode(true) }) - return unsubscribe }, []) const counts = useMemo((): Record => { const recentSet = new Set(recent) return { - all: games.length, - steam: games.filter((g) => g.source === 'steam').length, - epic: games.filter((g) => g.source === 'epic').length, - custom: games.filter((g) => g.source === 'custom').length, + all: games.length, + steam: games.filter((g) => g.source === 'steam').length, + epic: games.filter((g) => g.source === 'epic').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, - recent: games.filter((g) => recentSet.has(g.id)).length, + recent: games.filter((g) => recentSet.has(g.id)).length, } }, [games, favorites, recent]) @@ -49,9 +58,9 @@ export default function App() { list = games.filter((g) => favorites.includes(g.id)) } else if (category === 'recent') { const recentMap = new Map(recent.map((id, i) => [id, i])) - list = games.filter((g) => recentMap.has(g.id)) - list.sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999)) - // Apply search but skip sort (recent order preserved) + list = games + .filter((g) => recentMap.has(g.id)) + .sort((a, b) => (recentMap.get(a.id) ?? 999) - (recentMap.get(b.id) ?? 999)) if (search) { const q = search.toLowerCase() list = list.filter((g) => g.name.toLowerCase().includes(q)) @@ -73,7 +82,6 @@ export default function App() { ? a.name.localeCompare(b.name, 'ru') : b.name.localeCompare(a.name, 'ru'), ) - return list }, [games, category, search, sort, favorites, recent]) @@ -83,40 +91,41 @@ export default function App() { }, []) const handleToggleFavorite = useCallback(async (id: string) => { - const updated = await window.launcher.toggleFavorite(id) - setFavorites(updated) + setFavorites(await window.launcher.toggleFavorite(id)) }, []) - const handleAdd = useCallback(async (payload: Omit) => { - await window.launcher.addCustomGame(payload) - await reload() - }, [reload]) + // Custom games + const handleAdd = useCallback(async (g: Omit) => { await window.launcher.addCustomGame(g); await reload() }, [reload]) + const handleRemove = useCallback(async (id: string) => { await window.launcher.removeGame(id); await reload() }, [reload]) + const handleUpdate = useCallback(async (g: CustomGame) => { await window.launcher.updateGame(g); await reload() }, [reload]) - const handleRemove = useCallback(async (id: string) => { - await window.launcher.removeGame(id) - await reload() - }, [reload]) + // App entries + const handleAddApp = useCallback(async (a: Omit) => { await window.launcher.addApp(a); await reload() }, [reload]) + const handleRemoveApp = useCallback(async (id: string) => { await window.launcher.removeApp(id); await reload() }, [reload]) + const handleUpdateApp = useCallback(async (a: AppEntry) => { await window.launcher.updateApp(a); await reload() }, [reload]) - const handleUpdate = useCallback(async (game: CustomGame) => { - await window.launcher.updateGame(game) - await reload() - }, [reload]) + const closeAdmin = () => { setAdminOpen(false); setAdminMode(false) } - const closeAdmin = () => { - setAdminOpen(false) - setAdminMode(false) - } - - const hasEpic = counts.epic > 0 + const hasEpic = counts.epic > 0 const hasFavorites = favorites.length > 0 - const hasRecent = recent.length > 0 + const hasRecent = recent.length > 0 return ( -
+
{/* Header */} -
+
+ {/* Logo */}
- +
+ +
Club Launcher
@@ -133,20 +142,21 @@ export default function App() { - {/* Sort toggle */} + + @@ -154,7 +164,7 @@ export default function App() { @@ -168,13 +178,15 @@ export default function App() { onEdit={() => { if (!adminOpen) setAdminOpen(true) }} onToggleFavorite={handleToggleFavorite} adminMode={adminMode} + category={category} /> -
-

- {loading ? 'Загрузка...' : `${filtered.length} из ${games.length} игр`} - {adminMode && ● Режим администратора} -

+
+ {loading ? 'Загрузка...' : `${filtered.length} из ${games.length} записей`} + {adminMode && ● Режим администратора}
{adminOpen && ( @@ -184,6 +196,9 @@ export default function App() { onAdd={handleAdd} onRemove={handleRemove} onUpdate={handleUpdate} + onAddApp={handleAddApp} + onRemoveApp={handleRemoveApp} + onUpdateApp={handleUpdateApp} /> )}
diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 8b8a746..86ef8e0 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -1,16 +1,14 @@ -import { useState } from 'react' -import { X, Plus, Trash2, Edit2, FolderOpen, Image } from 'lucide-react' -import type { CustomGame, Game, GameFormData } from '../types' +import { useState, useEffect } from 'react' +import { X, Plus, Trash2, Edit2, FolderOpen, Image, Gamepad2, AppWindow } from 'lucide-react' +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 = { - name: '', - exe: '', - args: '', - image: '', - category: 'Other', -} +const EMPTY_FORM: GameFormData = { name: '', exe: '', args: '', image: '', category: 'Other' } + +type EntryKind = 'game' | 'app' +type Mode = 'list' | 'add' | 'edit' interface Props { games: Game[] @@ -18,68 +16,89 @@ interface Props { onAdd: (game: Omit) => Promise onRemove: (id: string) => Promise onUpdate: (game: CustomGame) => Promise + onAddApp: (app: Omit) => Promise + onRemoveApp: (id: string) => Promise + onUpdateApp: (app: AppEntry) => Promise } -type Mode = 'list' | 'add' | 'edit' +export function AdminPanel({ + games, onClose, + onAdd, onRemove, onUpdate, + onAddApp, onRemoveApp, onUpdateApp, +}: Props) { + const [mode, setMode] = useState('list') + const [kind, setKind] = useState('game') + const [form, setForm] = useState(EMPTY_FORM) + const [editId, setEditId] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [visible, setVisible] = useState(false) -export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) { - const [mode, setMode] = useState('list') - const [form, setForm] = useState(EMPTY_FORM) - const [editId, setEditId] = useState(null) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) + // 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 appEntries = games.filter((g): g is AppEntry => g.source === 'app') - const startAdd = () => { - setForm(EMPTY_FORM) + const startAdd = (k: EntryKind) => { + const defaultCat = k === 'game' ? 'Other' : 'Другое' + setForm({ ...EMPTY_FORM, category: defaultCat }) + setKind(k) setEditId(null) setError(null) setMode('add') } - const startEdit = (game: CustomGame) => { + const startEdit = (game: CustomGame | AppEntry) => { setForm({ - name: game.name, - exe: game.exe, - args: game.args.join(' '), - image: game.image, + name: game.name, + exe: game.exe, + args: game.args.join(' '), + image: game.image, category: game.category, }) + setKind(game.source === 'app' ? 'app' : 'game') setEditId(game.id) setError(null) setMode('edit') } - const pickExe = async () => { - const path = await window.launcher.pickExe() - if (path) setForm((f) => ({ ...f, exe: path })) - } - - const pickImage = async () => { - const path = await window.launcher.pickImage() - if (path) setForm((f) => ({ ...f, image: path })) - } + const pickExe = async () => { const p = await window.launcher.pickExe(); if (p) setForm((f) => ({ ...f, exe: p })) } + const pickImage = async () => { const p = await window.launcher.pickImage(); if (p) setForm((f) => ({ ...f, image: p })) } const handleSave = async () => { if (!form.name.trim()) { setError('Введите название'); return } - if (!form.exe.trim()) { setError('Выберите исполняемый файл'); return } + if (!form.exe.trim()) { setError('Выберите исполняемый файл'); return } setSaving(true) setError(null) try { const payload = { - name: form.name.trim(), - exe: form.exe.trim(), - args: form.args.trim() ? form.args.trim().split(/\s+/) : [], - image: form.image.trim(), + name: form.name.trim(), + exe: form.exe.trim(), + args: form.args.trim() ? form.args.trim().split(/\s+/) : [], + image: form.image.trim(), category: form.category, } - if (mode === 'edit' && editId) { - await onUpdate({ ...payload, id: editId, source: 'custom' }) + if (kind === 'game') { + if (mode === 'edit' && editId) await onUpdate({ ...payload, id: editId, source: 'custom' }) + else await onAdd(payload) } else { - await onAdd(payload) + if (mode === 'edit' && editId) await onUpdateApp({ ...payload, id: editId, source: 'app' }) + else await onAddApp(payload) } setMode('list') } 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 - 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 }) => ( +
+ {entry.image ? ( + {entry.name} { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : ( +
+ {isApp ? : } +
+ )} +
+

{entry.name}

+

{entry.exe}

+
+ + {entry.category} + + + +
+ ) + return ( -
-
+
{ 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' }} + > +
{/* Header */} -
+

- {mode === 'list' ? 'Управление играми' : mode === 'add' ? 'Добавить игру' : 'Редактировать игру'} + {mode === 'list' ? 'Управление' : mode === 'add' ? `Добавить ${itemTitle}` : `Редактировать ${itemTitle}`}

-

Admin Panel · Ctrl+Alt+A

+

Admin · Ctrl+Alt+A · Esc — закрыть

-
{/* Content */}
{mode === 'list' ? ( -
- {/* Custom games list */} - {customGames.length === 0 ? ( -

Нет добавленных игр

- ) : ( -
- {customGames.map((game) => ( -
+ {/* Custom games section */} +
+
+

+ Игры ({customGames.length}) +

+ +
+ {customGames.length === 0 + ?

Нет добавленных игр

+ :
{customGames.map((g) => )}
+ } +
+ + {/* Apps section */} +
+
+

+ Приложения ({appEntries.length}) +

+ +
+ {appEntries.length === 0 + ?

Нет добавленных приложений

+ :
{appEntries.map((a) => )}
+ } +
+ + {/* Info */} +

+ Steam ({games.filter((g) => g.source === 'steam').length}) и Epic ({games.filter((g) => g.source === 'epic').length}) определяются автоматически. +

+
+ ) : ( + /* Form */ +
+ {/* Kind toggle (only when adding) */} + {mode === 'add' && ( +
+ {(['game', 'app'] as EntryKind[]).map((k) => ( + - -
+ {k === 'game' ? <> Игра : <> Приложение} + ))}
)} - {/* Auto-detected info */} -

- Steam ({games.filter((g) => g.source === 'steam').length}) и Epic ( - {games.filter((g) => g.source === 'epic').length}) определяются автоматически. -

- - -
- ) : ( - /* Add / Edit form */ -
{/* Name */}
@@ -182,7 +238,7 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) type="text" value={form.name} 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" />
@@ -195,22 +251,18 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) type="text" value={form.exe} 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" /> -
{/* Args */}
- + - +
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" /> -
{form.image && ( preview { (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 }))} 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) => )}
@@ -268,19 +314,16 @@ export function AdminPanel({ games, onClose, onAdd, onRemove, onUpdate }: Props) )}
- {/* Footer for form modes */} + {/* Footer */} {mode !== 'list' && ( -
- diff --git a/src/components/CategoryFilter.tsx b/src/components/CategoryFilter.tsx index 9a43623..e5e60ee 100644 --- a/src/components/CategoryFilter.tsx +++ b/src/components/CategoryFilter.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect, useState } from 'react' import type { Category } from '../types' interface Props { @@ -11,28 +12,52 @@ interface Props { export function CategoryFilter({ active, counts, hasEpic, hasFavorites, hasRecent, onChange }: Props) { const tabs: { key: Category; label: string }[] = [ - { key: 'all', label: 'Все' }, - { key: 'steam', label: 'Steam' }, - ...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []), - { key: 'custom', label: 'Добавленные' }, + { key: 'all', label: 'Все' }, + { key: 'steam', label: 'Steam' }, + ...(hasEpic ? [{ key: 'epic' as Category, label: 'Epic' }] : []), + { key: 'custom', label: 'Игры' }, + { key: 'app', label: 'Приложения' }, ...(hasFavorites ? [{ key: 'favorites' as Category, label: '⭐ Избранное' }] : []), - ...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []), + ...(hasRecent ? [{ key: 'recent' as Category, label: '🕐 Последние' }] : []), ] + const containerRef = useRef(null) + const [indicator, setIndicator] = useState({ left: 0, width: 0 }) + const buttonRefs = useRef>(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 ( -
+
+ {/* Sliding indicator */} + + {tabs.map(({ key, label }) => ( diff --git a/src/components/GameCard.tsx b/src/components/GameCard.tsx index 6a5aebf..264bff3 100644 --- a/src/components/GameCard.tsx +++ b/src/components/GameCard.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react' +import { useState, useRef, useCallback } from 'react' import { Play, Settings, Star } from 'lucide-react' import type { Game } from '../types' -const COLORS = [ +// ── Placeholder ─────────────────────────────────────────────────────────── + +const GRADIENTS = [ ['#1a1a3e', '#2d1b69'], ['#1e3a5f', '#0d2137'], ['#2d1b2e', '#4a1942'], @@ -15,13 +17,12 @@ function nameHash(name: string): number { } 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 safe = name.replace(//g, '>') + const safe = name.replace(//g, '>') const svg = ` - - + ${initial} @@ -30,22 +31,44 @@ function makePlaceholder(name: string): string { return `data:image/svg+xml,${encodeURIComponent(svg)}` } -function getImageSrc(game: Game): string { - if (game.source === 'steam') return game.headerUrl - if (game.source === 'epic') return makePlaceholder(game.name) - if (game.image) { - if (game.image.startsWith('http')) return game.image - return `file://${game.image.replace(/\\/g, '/')}` - } - return makePlaceholder(game.name) +function makeAppPlaceholder(name: string): string { + const [c1, c2] = GRADIENTS[nameHash(name) % GRADIENTS.length] + const initial = (name[0] ?? '?').toUpperCase() + const svg = ` + + + + + ${initial} + ` + return `data:image/svg+xml,${encodeURIComponent(svg)}` } -const SOURCE_BADGE: Record = { - 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: 'CUSTOM' }, +function getImageSrc(game: Game): string { + if (game.source === 'steam') return game.headerUrl + if (game.source === 'epic') return makePlaceholder(game.name) + 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 = { + 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 { game: Game index: number @@ -54,81 +77,126 @@ interface Props { onToggleFavorite?: (id: string) => void isFavorite?: boolean adminMode?: boolean + isApp?: boolean // renders in square app-tile mode } -export function GameCard({ game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode }: Props) { - const [imgError, setImgError] = useState(false) +// ── Component ───────────────────────────────────────────────────────────── + +export function GameCard({ + game, index, onLaunch, onEdit, onToggleFavorite, isFavorite, adminMode, isApp, +}: Props) { + const [imgError, setImgError] = 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(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 () => { + setRipple(true) + setTimeout(() => setRipple(false), 500) setLaunching(true) - try { - await onLaunch(game.id) - } finally { - setTimeout(() => setLaunching(false), 2000) - } + try { await onLaunch(game.id) } + 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 (
- {/* Cover image */} -
+ {/* Cover */} +
{game.name} setImgError(true)} loading="lazy" />
- {/* Source badge */} - + {/* Badge */} + {badge.label} - {/* Favorite button */} + {/* Favorite */} {!adminMode && onToggleFavorite && ( )} - {/* Admin edit button */} + {/* Admin edit */} {adminMode && onEdit && ( )} - {/* Hover overlay: play button */} -
+ {/* Hover overlay */} +

{game.name}

diff --git a/src/components/GameGrid.tsx b/src/components/GameGrid.tsx index b0dda4e..5957b8f 100644 --- a/src/components/GameGrid.tsx +++ b/src/components/GameGrid.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect, useState } from 'react' import { Gamepad2 } from 'lucide-react' import { GameCard } from './GameCard' import type { Game } from '../types' @@ -10,23 +11,39 @@ interface Props { onEdit: (game: Game) => void onToggleFavorite: (id: string) => void adminMode: boolean + category: string } -function SkeletonCard() { +function SkeletonCard({ square }: { square?: boolean }) { return ( -
-
+
+
) } -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) { return (
-
+
{Array.from({ length: 12 }).map((_, i) => ( - + ))}
@@ -38,8 +55,12 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle
-

Игры не найдены

-

Убедитесь что Steam/Epic установлены или добавьте игры вручную

+

Ничего не найдено

+

+ {category === 'app' + ? 'Добавьте приложения через панель администратора' + : 'Убедитесь что Steam/Epic установлены или добавьте игры вручную'} +

) @@ -47,7 +68,7 @@ export function GameGrid({ games, loading, favorites, onLaunch, onEdit, onToggle return (
-
+
{games.map((game, i) => ( ))}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..a80e846 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -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 ( + + ) +} diff --git a/src/hooks/useGames.ts b/src/hooks/useGames.ts index 9ae857c..1a02b50 100644 --- a/src/hooks/useGames.ts +++ b/src/hooks/useGames.ts @@ -1,28 +1,36 @@ import { useState, useEffect, useCallback } from 'react' -import type { CustomGame, Game } from '../types' +import type { AppEntry, CustomGame, Game } from '../types' declare global { interface Window { launcher: { getGames: () => Promise launchGame: (id: string) => Promise<{ ok: boolean; error?: string; recent?: string[] }> - addCustomGame: (game: Omit) => Promise - removeGame: (id: string) => Promise<{ ok: boolean }> - updateGame: (game: CustomGame) => Promise<{ ok: boolean }> - pickExe: () => Promise + // Custom games + addCustomGame: (g: Omit) => Promise + removeGame: (id: string) => Promise<{ ok: boolean }> + updateGame: (g: CustomGame) => Promise<{ ok: boolean }> + // App entries + addApp: (a: Omit) => Promise + removeApp: (id: string) => Promise<{ ok: boolean }> + updateApp: (a: AppEntry) => Promise<{ ok: boolean }> + // Dialogs + pickExe: () => Promise pickImage: () => Promise - onAdminOpen: (cb: () => void) => () => void - getFavorites: () => Promise + // Favorites / recent + getFavorites: () => Promise toggleFavorite: (id: string) => Promise - getRecent: () => Promise + getRecent: () => Promise + // Events + onAdminOpen: (cb: () => void) => () => void } } } export function useGames() { - const [games, setGames] = useState([]) + const [games, setGames] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const load = useCallback(async () => { setLoading(true) @@ -37,9 +45,7 @@ export function useGames() { } }, []) - useEffect(() => { - load() - }, [load]) + useEffect(() => { load() }, [load]) return { games, loading, error, reload: load } } diff --git a/src/index.css b/src/index.css index 69f82d4..718a8c4 100644 --- a/src/index.css +++ b/src/index.css @@ -2,35 +2,52 @@ @tailwind components; @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 { - * { - box-sizing: border-box; - } + * { box-sizing: border-box; } body { margin: 0; padding: 0; - background-color: #0f0f0f; - color: #e2e8f0; + background-color: var(--bg); + color: var(--text); font-family: -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; overflow: hidden; + transition: background-color 0.2s, color 0.2s; } - ::-webkit-scrollbar { - width: 6px; - } - - ::-webkit-scrollbar-track { - background: #0f0f0f; - } - - ::-webkit-scrollbar-thumb { - background: #1e293b; - border-radius: 3px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #334155; - } + ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: var(--bg); } + ::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { background: var(--scrollH); } } diff --git a/src/types.ts b/src/types.ts index 5db37d9..722c17f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,9 +26,19 @@ export interface CustomGame { 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' diff --git a/tailwind.config.ts b/tailwind.config.ts index e3e7552..2afdd04 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,35 +2,67 @@ import type { Config } from 'tailwindcss' export default { content: ['./src/**/*.{ts,tsx}', './index.html'], + darkMode: 'class', theme: { extend: { colors: { - bg: '#0f0f0f', - card: '#1a1a2e', - cardHover: '#16213e', - accent: '#22c55e', - accentHover: '#16a34a', - surface: '#0f3460', - text: '#e2e8f0', - muted: '#64748b', - border: '#1e293b', + bg: 'var(--bg)', + card: 'var(--card)', + cardHover: 'var(--cardHover)', + accent: 'var(--accent)', + accentHover: 'var(--accentH)', + surface: 'var(--surface)', + text: 'var(--text)', + muted: 'var(--muted)', + border: 'var(--border)', }, aspectRatio: { steam: '460 / 215', + app: '1 / 1', }, animation: { - 'fade-in': 'fadeIn 0.22s ease-out both', - shimmer: 'shimmer 1.4s ease-in-out infinite', + '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', + 'bounce-star': 'bounceStar 0.35s ease-out', + ripple: 'ripple 0.5s ease-out', }, keyframes: { fadeIn: { 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: { '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)', }, }, },