Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/Sidebar.tsx
T
Zimin A.N. f72aac6398 fix(admin-ui): sidebar font sizes per handoff spec
- 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.
2026-05-11 13:56:38 +03:00

207 lines
6.7 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 { 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>
)
}