import { useState } from 'react' import { Link, useLocation } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Home, Database, FileText, ClipboardList, Inbox, Webhook, ScrollText, Search, type LucideIcon, } from 'lucide-react' import { useAuth } from 'react-oidc-context' import { useCanMutate } from '@/auth/useCanMutate' import { useDictionaries, useMyDrafts } from '@/api/queries' import { Sheet, SheetContent } from '@/ui' import { ThemeSwitch } from '@/components/layout/ThemeSwitch' import { cn } from '@/lib/utils' /** * Sidebar — 220px nav rail per handoff design system. * * Structure (top-to-bottom): * - Logo block (ORDINIS wordmark в Tektur) * - Section: navigation rail (Home, Справочники [count], Поиск, Граф) * - Section: admin-only (Мои черновики, На ревью [count], Аудит, Outbox, Webhooks) * - User profile (bottom, sticky) * * Responsive: на <1024px collapse в icon-only rail (56px). Smaller — overlay * drawer (TODO Stage 3.x). Сейчас always-visible — main grid сам адаптирует. */ type NavSection = { /** Optional .cap header above items. */ label?: string items: NavItem[] } type NavItem = { to: string label: string icon: LucideIcon /** Badge content справа (число pending / counts). */ badge?: React.ReactNode /** Только аутентифицированным пользователям. */ authRequired?: boolean } export function Sidebar() { const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() const dictsQuery = useDictionaries() // Loaded just for сидбар counter — light request, кэшируется. const myDrafts = useMyDrafts(0, 1) const dictsCount = dictsQuery.data?.length ?? 0 const myDraftsCount = myDrafts.data?.totalElements ?? 0 // Reviews count бэйдж — TODO когда будет cheap endpoint /drafts/pending/count. // Сейчас GET /drafts/pending возвращает page, paged запрос на каждый mount // sidebar'а — overkill. Skip badge. const reviewsCount = 0 // Flat list per redesign/compact.html — no section labels, /graph removed // (now reachable via catalog toolbar "Граф ⇄" button). Order: Главная → // Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск. const sections: NavSection[] = [ { items: [ { to: '/', label: t('nav.home'), icon: Home }, { to: '/dictionaries', label: t('nav.dictionaries'), icon: Database, badge: dictsCount > 0 ? dictsCount : undefined, }, { to: '/my-drafts', label: t('nav.myDrafts'), icon: FileText, badge: myDraftsCount > 0 ? myDraftsCount : undefined, authRequired: true, }, { to: '/reviews', label: t('nav.reviews'), icon: ClipboardList, badge: reviewsCount > 0 ? reviewsCount : undefined, authRequired: true, }, { to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true }, { to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true }, { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true }, { to: '/search', label: t('nav.search'), icon: Search }, ], }, ] const visibleSections = sections .map((s) => ({ ...s, items: s.items.filter((i) => !i.authRequired || canMutate), })) .filter((s) => s.items.length > 0) // Sidebar collapsed mode — per handoff prototype 'Свернуть' button bottom-left. // Persists в localStorage. Collapsed = icon-only 56px rail, expanded = 220px. const [collapsed, setCollapsed] = useState(() => { if (typeof window === 'undefined') return false return window.localStorage.getItem('ord-sidebar-collapsed') === '1' }) const toggleCollapsed = () => { setCollapsed((prev) => { const next = !prev try { window.localStorage.setItem('ord-sidebar-collapsed', next ? '1' : '0') } catch { // localStorage может быть disabled } return next }) } return ( ) } /** * SidebarContent — внутренности (logo + nav + user). Используется в desktop * rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY. */ function SidebarContent({ sections, auth, onNavigate, collapsed = false, onToggleCollapsed, }: { sections: NavSection[] auth: ReturnType /** Called после клика на nav item — для close drawer в mobile mode. */ onNavigate?: () => void /** Desktop collapse state — affects labels visibility и width. */ collapsed?: boolean /** Toggle button handler. When undefined — collapse button hidden (mobile). */ onToggleCollapsed?: () => void }) { return ( <> {/* Logo block per redesign prototype: circle (accent) + ORDINIS + MDM mute */}
{/* Ordinis mark — orbital structure recolored под Ordinis palette. При мелком размере (28px) тонкие линии/маленькие dots становились почти невидимыми. Bumped strokeWidth до 5 + dot radii до 14/11 (внутри 256 viewBox = читаемые ~1.5px / 1.2px при 28px render). Theme-aware через currentColor + var(--color-line). */} {/* Outer orbits — line tone, bolder stroke для видимости при 28px */} {/* Outer orbit dots (3) — large accent dots */} {/* Inner orbit dots (3) */} {/* Core — solid accent, prominent */} {!collapsed && ( ORDINIS MDM )}
{/* Nav sections */} {/* Footer: Lang toggle + User profile (or Sign-in) + Collapse toggle. * Moved from TopBar per user feedback: "вход в сайдбар, ru/en в сайдбаре". */} ) } function SidebarFooter({ auth, collapsed, onToggleCollapsed, }: { auth: ReturnType collapsed: boolean onToggleCollapsed?: () => void }) { const { t, i18n } = useTranslation() const cur = i18n.language?.toLowerCase().startsWith('en') ? 'en' : 'ru' const next = cur === 'ru' ? 'en-US' : 'ru-RU' return ( <> {/* Language toggle + Theme switch row (только когда expanded). * ThemeSwitch и LanguageSwitch переехали из TopBar per user * ("свитч темы можно тоже в профиль перенести"). */} {!collapsed && (
)} {/* Auth block: avatar + name (authed) OR Sign-in CTA (anonymous). */} {auth.isAuthenticated && auth.user?.profile ? ( ) : ( )} {/* Collapse toggle */} {onToggleCollapsed && ( )} ) } function AuthedUserBlock({ auth, collapsed, }: { auth: ReturnType collapsed: boolean }) { const { t } = useTranslation() const profile = auth.user?.profile if (!profile) return null const display = String( profile.preferred_username || profile.name || profile.email || 'user', ) return (
{display.charAt(0).toUpperCase()}
{!collapsed && (
{display}
{profile.email && (
{String(profile.email)}
)}
)} {!collapsed && ( )}
) } function SignInCta({ auth, collapsed, }: { auth: ReturnType collapsed: boolean }) { const { t } = useTranslation() return ( ) } /** * MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается * через hamburger button в TopBar (<1024px), закрывается на: backdrop click, * Escape, или клик на любой nav item (auto-close, привычная mobile UX). * *

Hooks logic дублируется (auth, dictsCount etc.) — нужно для filter * auth-required sections. Можно вынести в useSidebarSections() hook * следующим refactor. */ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() const dictsQuery = useDictionaries() const myDrafts = useMyDrafts(0, 1) const dictsCount = dictsQuery.data?.length ?? 0 const myDraftsCount = myDrafts.data?.totalElements ?? 0 const reviewsCount = 0 // Flat list per redesign — same as desktop Sidebar above. const sections: NavSection[] = [ { items: [ { to: '/', label: t('nav.home'), icon: Home }, { to: '/dictionaries', label: t('nav.dictionaries'), icon: Database, badge: dictsCount > 0 ? dictsCount : undefined, }, { to: '/my-drafts', label: t('nav.myDrafts'), icon: FileText, badge: myDraftsCount > 0 ? myDraftsCount : undefined, authRequired: true, }, { to: '/reviews', label: t('nav.reviews'), icon: ClipboardList, badge: reviewsCount > 0 ? reviewsCount : undefined, authRequired: true, }, { to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true }, { to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true }, { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true }, { to: '/search', label: t('nav.search'), icon: Search }, ], }, ] const visibleSections = sections .map((s) => ({ ...s, items: s.items.filter((i) => !i.authRequired || canMutate) })) .filter((s) => s.items.length > 0) return ( { if (!v) onClose() }}> ) } function SidebarLink({ to, label, icon: Icon, badge, onClick, collapsed = false, }: NavItem & { onClick?: () => void; collapsed?: boolean }) { const location = useLocation() const active = to === '/' ? location.pathname === '/' : location.pathname === to || location.pathname.startsWith(to + '/') return ( {!collapsed && ( <> {label} {badge !== undefined && ( {badge} )} )} ) }