Files
pgames-manager/frontend/src/App.tsx
T
mrleo1nidandClaude Sonnet 5.5 47fc121e7d
CI / frontend (push) Successful in 21s
CI / backend (push) Successful in 47s
feat: local browser saves with opt-in cloud sync behind saves.cloud permission
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:44:42 +03:00

140 lines
6.7 KiB
TypeScript

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";
import { Library } from "./pages/Library";
import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame";
import { ModEditor } from "./pages/ModEditor";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { Saves } from "./pages/Saves";
import { TranslationEditor } from "./pages/TranslationEditor";
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";
import { UploadProvider } from "./uploads";
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
const { can } = useAuth();
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>
<NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" 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, 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 />;
return (
<UploadProvider key={me.id}>
<Routes>
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
<Route
path="*"
element={
<div className="layout">
<header className="topbar">
<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>
<UserMenu />
</header>
<main>
<Routes>
<Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} />
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} />
<Route path="/saves" element={<Saves />} />
<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>
}
/>
</Routes>
</UploadProvider>
);
}