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:
1 parent
51a7e77e56
commit
9ee3e1ff6e
27 files changed
+2399
-508
No files matched your search
+84
-15
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user