UI: кнопка «Меню» (гамбургер) в нижней панели → боковое меню справа (плашка аккаунта + Выйти)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
@@ -5,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
|||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
"/": "Forbidden Stars",
|
"/": "Forbidden Stars",
|
||||||
@@ -19,6 +21,7 @@ export function AppShell() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
|
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
|
||||||
const title =
|
const title =
|
||||||
TITLES[location.pathname] ??
|
TITLES[location.pathname] ??
|
||||||
@@ -49,7 +52,8 @@ export function AppShell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomBar />
|
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
|
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Plus, Settings, Users } from "lucide-react";
|
import { Menu, Plus, Users } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
|
|
||||||
export function BottomBar() {
|
export function BottomBar({ onMenu }: { onMenu: () => void }) {
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -29,9 +29,9 @@ export function BottomBar() {
|
|||||||
|
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
|
||||||
<button className="slot" onClick={() => navigate("/account")}>
|
<button className="slot" onClick={onMenu} aria-label="Меню">
|
||||||
<Settings size={20} />
|
<Menu size={20} />
|
||||||
<span>Аккаунт</span>
|
<span>Меню</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useLogout, useMe } from "../hooks/auth";
|
||||||
|
import { useMyStats } from "../hooks/stats";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
|
||||||
|
/** Боковое меню, выезжающее из правого края. Сверху — кликабельная плашка аккаунта
|
||||||
|
* (краткая сводка → /account), ниже — выход. Открытием управляет AppShell. */
|
||||||
|
export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data: stats } = useMyStats();
|
||||||
|
const logout = useLogout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const goAccount = () => {
|
||||||
|
onClose();
|
||||||
|
navigate("/account");
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLogout = async () => {
|
||||||
|
onClose();
|
||||||
|
await logout.mutateAsync().catch(() => {});
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={"drawer-overlay" + (open ? " open" : "")} onClick={onClose} />
|
||||||
|
<aside className={"side-menu" + (open ? " open" : "")} aria-hidden={!open}>
|
||||||
|
{me && (
|
||||||
|
<button className="side-account" onClick={goAccount}>
|
||||||
|
<Avatar url={me.avatar_url} nickname={me.nickname} size={48} />
|
||||||
|
<span className="side-account-info">
|
||||||
|
<b>{me.nickname}</b>
|
||||||
|
<span className="muted small">
|
||||||
|
игр {stats?.overall.games ?? 0} · побед {stats?.overall.wins ?? 0}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="side-menu-items">
|
||||||
|
<button className="side-menu-item danger" onClick={doLogout}>
|
||||||
|
<LogOut size={18} /> Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -194,3 +194,64 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||||
|
|
||||||
|
/* ─── Боковое меню (выезжает справа) ─────────────────────────────────────────── */
|
||||||
|
.drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: min(300px, 84vw);
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
z-index: 61;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: var(--safe-top);
|
||||||
|
}
|
||||||
|
.side-menu.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.side-account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.side-account-info { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.side-account-info b { font-size: 16px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.side-menu-items { display: flex; flex-direction: column; padding: 8px; }
|
||||||
|
.side-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.side-menu-item.danger { color: var(--danger); }
|
||||||
|
|||||||
Reference in New Issue
Block a user