f72aac6398
- Section labels (WORKFLOW / АДМИНИСТРИРОВАНИЕ): text-2xs (12px) → 10.5px Tektur per handoff caption spec. "Администрирование" не вылазит за 220px sidebar. - Nav items: text-sm (14px) → text-[13px] handoff body workhorse size. - Badge counters: text-2xs (12px) → text-[11px] (mono IDs spec). Partial — full font audit pending (Stage 3.x font sweep). См. nsi-hot.md.
207 lines
6.7 KiB
TypeScript
207 lines
6.7 KiB
TypeScript
import { Link, useLocation } from '@tanstack/react-router'
|
||
import { useTranslation } from 'react-i18next'
|
||
import {
|
||
Home,
|
||
Database,
|
||
FileText,
|
||
ClipboardList,
|
||
Inbox,
|
||
Webhook,
|
||
ScrollText,
|
||
Search,
|
||
GitBranch,
|
||
type LucideIcon,
|
||
} from 'lucide-react'
|
||
import { useAuth } from 'react-oidc-context'
|
||
import { useCanMutate } from '@/auth/useCanMutate'
|
||
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
||
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
|
||
|
||
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: '/search', label: t('nav.search'), icon: Search },
|
||
// Graph view — Stage 3.3 placeholder. Когда d3-force готов.
|
||
{ to: '/graph', label: t('nav.graph'), icon: GitBranch },
|
||
],
|
||
},
|
||
{
|
||
label: t('nav.section.workflow'),
|
||
items: [
|
||
{
|
||
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,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
label: t('nav.section.admin'),
|
||
items: [
|
||
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true },
|
||
{ to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true },
|
||
{ to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true },
|
||
],
|
||
},
|
||
]
|
||
|
||
const visibleSections = sections
|
||
.map((s) => ({
|
||
...s,
|
||
items: s.items.filter((i) => !i.authRequired || canMutate),
|
||
}))
|
||
.filter((s) => s.items.length > 0)
|
||
|
||
return (
|
||
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
|
||
{/* Logo block */}
|
||
<div className="h-14 flex items-center px-5 border-b border-line">
|
||
<Link to="/" className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity">
|
||
ORDINIS
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Nav sections */}
|
||
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
||
{visibleSections.map((section, idx) => (
|
||
<div key={idx} className="flex flex-col gap-0.5">
|
||
{section.label && (
|
||
<div className="px-3 py-1 font-display uppercase tracking-[0.10em] text-mute text-[10.5px] leading-none">
|
||
{section.label}
|
||
</div>
|
||
)}
|
||
{section.items.map((item) => (
|
||
<SidebarLink key={item.to} {...item} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
|
||
{/* User block — sticky bottom */}
|
||
{auth.isAuthenticated && auth.user?.profile && (
|
||
<div className="border-t border-line px-3 py-3 flex items-center gap-2">
|
||
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-xs font-semibold">
|
||
{String(
|
||
auth.user.profile.preferred_username ||
|
||
auth.user.profile.name ||
|
||
'U',
|
||
)
|
||
.charAt(0)
|
||
.toUpperCase()}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-xs font-medium text-ink truncate">
|
||
{String(
|
||
auth.user.profile.preferred_username ||
|
||
auth.user.profile.name ||
|
||
auth.user.profile.email ||
|
||
'user',
|
||
)}
|
||
</div>
|
||
{auth.user.profile.email && (
|
||
<div className="text-2xs text-mute truncate">{String(auth.user.profile.email)}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
function SidebarLink({ to, label, icon: Icon, badge }: NavItem) {
|
||
const location = useLocation()
|
||
const active =
|
||
to === '/'
|
||
? location.pathname === '/'
|
||
: location.pathname === to || location.pathname.startsWith(to + '/')
|
||
|
||
return (
|
||
<Link
|
||
to={to}
|
||
className={cn(
|
||
// 13px per handoff spec для workhorse body text. text-sm = 14px (Tailwind),
|
||
// text-[13px] = exact handoff value.
|
||
'group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-[13px] transition-colors',
|
||
active
|
||
? 'bg-accent-bg text-accent font-medium'
|
||
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||
)}
|
||
>
|
||
<Icon size={15} strokeWidth={1.75} className="shrink-0" />
|
||
<span className="flex-1 truncate">{label}</span>
|
||
{badge !== undefined && (
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-[11px] font-mono',
|
||
active
|
||
? 'bg-accent text-on-accent'
|
||
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
|
||
)}
|
||
>
|
||
{badge}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
}
|