Files
vidconf/frontend/src/components/layout/ShellTopbar.tsx

118 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { CalendarDays, LayoutDashboard, LogOut, Menu, Users, Video } from 'lucide-react'
import { useAuth } from '@/auth/useAuth'
import { Avatar } from '@/components/ui/Avatar'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
const NAV_LINKS = [
{ to: '/lobby', label: 'Лобби', icon: Video },
{ to: '/calendar', label: 'Календарь', icon: CalendarDays },
{ to: '/my-conferences', label: 'Мои конференции', icon: Users },
]
/** Доступен только администраторам — сама админка тоже защищена route-guard'ом (см. App.tsx). */
const ADMIN_NAV_LINK = { to: '/admin', label: 'Админка', icon: LayoutDashboard }
/**
* Верхняя навигация светлой оболочки (лобби/календарь/мои конференции/админка),
* см. design/mockups/lobby.html.
*
* Пункты навигации убраны в скрывающуюся панель за бургером (`.menu-wrap` +
* `.burger` + `.menu`) — как в макете; список ссылок там расширен нашими
* реальными разделами (в самом макете в панели — заглушки «Скачать
* приложение»/«О сервисе», которых как отдельных экранов нет). Бургер виден
* всегда, а не только на мобильной ширине — макет не прячет его по media
* query ни на одной ширине, это единственный способ навигации между
* разделами оболочки.
*
* Пилюля с ФИО теперь ссылка на страницу профиля
* (`/profile`) — в макете это была декоративная заглушка без перехода;
* аватар — общий компонент `Avatar` (фото или инициалы).
*/
export function ShellTopbar() {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const menuWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!menuOpen) return
function handlePointerDown(event: MouseEvent) {
if (menuWrapRef.current && !menuWrapRef.current.contains(event.target as Node)) {
setMenuOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [menuOpen])
async function handleLogout() {
setMenuOpen(false)
await logout()
navigate('/login', { replace: true })
}
return (
<header className="topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<div className="topbar-right">
<ThemeToggle />
<Link to="/profile" className="user-pill">
<Avatar name={user?.name_user ?? '?'} avatarUrl={user?.avatar_url} />
<span className="user-pill-name">{user?.name_user ?? '—'}</span>
</Link>
<div className="menu-wrap" ref={menuWrapRef}>
<button
type="button"
className="burger"
aria-expanded={menuOpen}
aria-controls="main-menu"
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
onClick={() => setMenuOpen((v) => !v)}
>
<Menu className="icon" aria-hidden="true" />
</button>
<nav className={`menu${menuOpen ? ' is-open' : ''}`} id="main-menu" aria-label="Основное меню">
{[...NAV_LINKS, ...(user?.role === 'admin' ? [ADMIN_NAV_LINK] : [])].map((link) => {
const Icon = link.icon
return (
<Link
key={link.to}
to={link.to}
className={location.pathname.startsWith(link.to) ? 'is-active' : ''}
onClick={() => setMenuOpen(false)}
>
<Icon className="icon" aria-hidden="true" />
{link.label}
</Link>
)
})}
<div className="menu-divider" />
<button type="button" className="danger" onClick={handleLogout}>
<LogOut className="icon" aria-hidden="true" />
Выйти
</button>
</nav>
</div>
</div>
</header>
)
}