UI: unified design system, light/dark themes, redesigned pages and player UI

Tokens and components in styles.css, shared Ui (dialogs, toasts), icons,
bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with
filters and conversion badges, hero game page, restyled player screens.
Games list now returns per-game version counts. Adds docs/UI_DESIGN.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:43:53 +03:00
1 parent 51a7e77e56
commit 9ee3e1ff6e
27 files changed
+2399 -508

No files matched your search

+84 -15
View File
@@ -1,4 +1,5 @@
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { Navigate, NavLink, Route, Routes, useLocation } from "react-router-dom";
import { useAuth } from "./auth";
import { ChangeCredentials } from "./pages/ChangeCredentials";
import { GamePage } from "./pages/GamePage";
@@ -8,15 +9,78 @@ import { NewGame } from "./pages/NewGame";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { Users } from "./pages/Users";
import { useTheme } from "./theme";
import { Icon } from "./ui/Icon";
import { useSpatialNav } from "./ui/nav";
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
const { can } = useAuth();
return can(perm) ? children : <div className="page"><p className="muted">Нет доступа к этой странице.</p></div>;
return can(perm) ? children : (
<div className="page"><p className="muted">Нет доступа к этой странице.</p></div>
);
}
function UserMenu() {
const { me, logout, can } = useAuth();
const { choice, cycle } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { pathname } = useLocation();
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (!me) return null;
return (
<div className="menu" ref={ref}>
<button className="btn btn-ghost menu-trigger" onClick={() => setOpen((o) => !o)}
aria-haspopup="menu" aria-expanded={open} aria-label="Меню профиля">
<span className="avatar">{me.username[0]?.toUpperCase()}</span>
<span className="menu-label">{me.username}</span>
<Icon name="chevron" size={14} className="menu-chevron" style={{ transform: "rotate(90deg)" }} />
</button>
{open && (
<div className="menu-panel" role="menu">
<div className="menu-head">
<b>{me.username}</b>
<div className="muted small">{me.role.name}</div>
</div>
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
{(can("users.manage") || can("roles.manage")) && <div className="menu-section">Администрирование</div>}
{can("users.manage") && (
<NavLink to="/admin/users" className="menu-item" role="menuitem"><Icon name="users" size={18} />Пользователи</NavLink>
)}
{can("roles.manage") && (
<NavLink to="/admin/roles" className="menu-item" role="menuitem"><Icon name="shield" size={18} />Роли и права</NavLink>
)}
<div className="menu-sep" />
<button className="menu-item" role="menuitem" onClick={cycle}>
<Icon name={THEME_ICON[choice]} size={18} />{THEME_LABEL[choice]}
</button>
<button className="menu-item danger" role="menuitem" onClick={() => void logout()}>
<Icon name="logout" size={18} />Выйти
</button>
</div>
)}
</div>
);
}
export function App() {
const { me, loading, logout, can } = useAuth();
if (loading) return <div className="center muted">Загрузка…</div>;
const { me, loading, can } = useAuth();
useSpatialNav();
if (loading) return <div className="center"><span className="spinner" /></div>;
if (!me) return <Login />;
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
@@ -28,18 +92,16 @@ export function App() {
element={
<div className="layout">
<header className="topbar">
<NavLink to="/" className="brand">PGames</NavLink>
<nav>
<NavLink to="/" end>Библиотека</NavLink>
{can("games.upload") && <NavLink to="/games/new">Загрузить игру</NavLink>}
{can("users.manage") && <NavLink to="/admin/users">Пользователи</NavLink>}
{can("roles.manage") && <NavLink to="/admin/roles">Роли</NavLink>}
<NavLink to="/" className="brand">
<span className="brand-mark"><Icon name="play" size={16} /></span>PGames
</NavLink>
<nav aria-label="Основная навигация">
<NavLink to="/" end className="nav-link"><Icon name="library" size={18} />Библиотека</NavLink>
{can("games.upload") && (
<NavLink to="/games/new" className="nav-link"><Icon name="upload" size={18} />Загрузить игру</NavLink>
)}
</nav>
<div className="user">
<NavLink to="/account">{me.username}</NavLink>
<span className="muted"> · {me.role.name}</span>
<button className="link" onClick={() => void logout()}>Выйти</button>
</div>
<UserMenu />
</header>
<main>
<Routes>
@@ -52,6 +114,13 @@ export function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<nav className="tabbar" aria-label="Нижняя навигация">
<NavLink to="/" end><Icon name="library" />Библиотека</NavLink>
{can("games.upload") && <NavLink to="/games/new"><Icon name="upload" />Загрузить</NavLink>}
{can("users.manage") && <NavLink to="/admin/users"><Icon name="users" />Люди</NavLink>}
{can("roles.manage") && <NavLink to="/admin/roles"><Icon name="shield" />Роли</NavLink>}
<NavLink to="/account"><Icon name="user" />Профиль</NavLink>
</nav>
</div>
}
/>