Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Menu, Radio, X } from 'lucide-react'
|
|
import { useAuthStore } from '@/features/auth/store'
|
|
import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
|
import { useTheme } from '@/theme/ThemeProvider'
|
|
import { setLanguage } from '@/shared/lib/i18n'
|
|
import { cn } from '@/shared/lib/cn'
|
|
|
|
export const Route = createRootRoute({ component: RootLayout })
|
|
|
|
function RootLayout() {
|
|
const { t, i18n } = useTranslation()
|
|
const { user } = useAuthStore()
|
|
const { theme, setTheme } = useTheme()
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
void bootstrapSession()
|
|
}, [])
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await logout()
|
|
} finally {
|
|
clearSession()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
<header className="border-b border-border">
|
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
|
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
|
<Radio className="h-5 w-5" />
|
|
{t('appName')}
|
|
</Link>
|
|
|
|
<nav className="hidden items-center gap-4 text-sm md:flex">
|
|
{user && (
|
|
<>
|
|
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
|
{t('nav.dashboard')}
|
|
</Link>
|
|
<Link to="/settings" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
|
{t('nav.settings')}
|
|
</Link>
|
|
{user.role === 'admin' && (
|
|
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
|
{t('nav.admin')}
|
|
</Link>
|
|
)}
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase"
|
|
value={i18n.language}
|
|
onChange={(e) => setLanguage(e.target.value)}
|
|
>
|
|
<option value="ru">{t('lang.ru')}</option>
|
|
<option value="en">{t('lang.en')}</option>
|
|
</select>
|
|
<select
|
|
className="hidden rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase sm:block"
|
|
value={theme}
|
|
onChange={(e) => setTheme(e.target.value as typeof theme)}
|
|
>
|
|
<option value="light">{t('theme.light')}</option>
|
|
<option value="dark">{t('theme.dark')}</option>
|
|
<option value="system">{t('theme.system')}</option>
|
|
</select>
|
|
|
|
{user ? (
|
|
<button
|
|
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
|
onClick={() => void handleLogout()}
|
|
>
|
|
{t('nav.logout')}
|
|
</button>
|
|
) : (
|
|
<Link
|
|
to="/login"
|
|
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
|
>
|
|
{t('nav.login')}
|
|
</Link>
|
|
)}
|
|
|
|
<button
|
|
className="rounded-sm border border-border p-1.5 md:hidden"
|
|
onClick={() => setMenuOpen((v) => !v)}
|
|
aria-label="Menu"
|
|
>
|
|
{menuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{menuOpen && (
|
|
<nav className="flex flex-col gap-1 border-t border-border px-4 py-3 text-sm md:hidden">
|
|
{user ? (
|
|
<>
|
|
<Link to="/dashboard" onClick={() => setMenuOpen(false)}>
|
|
{t('nav.dashboard')}
|
|
</Link>
|
|
<Link to="/settings" onClick={() => setMenuOpen(false)}>
|
|
{t('nav.settings')}
|
|
</Link>
|
|
{user.role === 'admin' && (
|
|
<Link to="/admin" onClick={() => setMenuOpen(false)}>
|
|
{t('nav.admin')}
|
|
</Link>
|
|
)}
|
|
<button className="py-1 text-left" onClick={() => void handleLogout()}>
|
|
{t('nav.logout')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<Link to="/login" onClick={() => setMenuOpen(false)}>
|
|
{t('nav.login')}
|
|
</Link>
|
|
)}
|
|
</nav>
|
|
)}
|
|
</header>
|
|
|
|
<main className={cn('mx-auto w-full max-w-5xl flex-1 px-4 py-8')}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|