Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/Sidebar.tsx
T
Zimin A.N. 7e435e07b7 feat(auth): миграция oidc-client-ts → keycloak-js (altum-pattern)
Phase 1a из плана: только swap библиотеки, realm/URL остаются на
auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service)
— отдельный эпик.

Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный
POST /token loop когда refresh_token истекал (см. MR !269 revert,
~30+ 400'к в console, страница залипала на 403). Altum давно ушёл
с oidc-client-ts на keycloak-js по той же причине.

Новая модель.
- src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) +
  ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало
  altum/src/lib/keycloak.ts.
- src/stores/authStore.ts — Zustand store: {user, isAuthenticated,
  isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents()
  → onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store.
- src/auth/useAuth.ts — compat shim для react-oidc-context API
  (auth.user.profile, isAuthenticated, signinSilent, signinRedirect,
  signoutRedirect, removeUser, error). Все 11 callsites продолжают
  работать без правок (только сменили путь import'а).
- src/api/client.ts — request interceptor дёргает ensureFreshToken(30)
  ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor
  делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких
  setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source
  of truth.
- src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто
  useAuthStore.getState().checkAuth() на старте, потом обычный render.
- public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri.

Удалены.
- src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах.
- src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts.
- npm deps: react-oidc-context, oidc-client-ts.

Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13.

Tests: 171/171 passed, TSC чистый.

Migration callsites — все unchanged по семантике благодаря compat shim.
2026-05-26 11:18:30 +03:00

673 lines
26 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 { useState } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Database,
FileText,
ClipboardList,
Inbox,
Webhook,
ScrollText,
Search,
type LucideIcon,
} from 'lucide-react'
import { useAuth } from '@/auth/useAuth'
import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
useCanReviewSchema,
useHasInternalScope,
} from '@/auth/usePermissions'
import {
useMyDrafts,
useMySchemaDrafts,
useOutboxStats,
useReviewsBadgeCount,
useWebhookSubscriptions,
} from '@/api/queries'
import { Sheet, SheetContent, SheetTitle } from '@/ui'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { classifyRecordStatus, classifySchemaStatus } from '@/components/reviews/MyDraftsPanel'
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
/**
* Visible только если user имеет хотя бы одну из workflow ролей
* (или {@code ordinis:admin} super-role). Hide если нет.
*/
reviewerOnly?: boolean
/**
* Admin-only nav entries (outbox/audit/webhooks). Backend возвращает 403
* audit_internal_required / outbox_internal_required для PUBLIC-scope users.
* Frontend hide'ит вместо показа links → 403 toast (confusing UX).
* Hide когда {@link useHasInternalScope} returns false.
*/
internalOnly?: boolean
}
export function Sidebar() {
const { t } = useTranslation()
const auth = useAuth()
const canMutate = useCanMutate()
// Phase 1d role-based sidebar visibility. /reviews пункт показываем
// только тем у кого есть schema:reviewer OR record:reviewer OR
// ordinis:admin super-role. Иначе пункт скрыт — у user'а нет смысла
// его видеть (clicking всё равно даст empty queue / 403).
const canReviewSchema = useCanReviewSchema()
const canReviewRecord = useCanReviewRecord()
const isReviewer = canReviewSchema || canReviewRecord
// Loaded just for сидбар counter — light request, кэшируется.
// Badge "Мои черновики" суммирует record (/drafts/me) + schema
// (/admin/schema-drafts/me) — maker не должен помнить разные типы.
//
// page=0 size=50 (не size=1!) — backend /drafts/me возвращает ВСЕ статусы
// (PENDING + APPROVED + REJECTED + WITHDRAWN), а totalElements не умеет
// filter по статусу. Нужно фильтровать на клиенте: считаем только
// pending/WIP-драфты, чтобы approved/rejected не «висели» в badge.
// Reuse: на /reviews этот же query (size=50) уже зовётся → TanStack
// дедуплицирует по queryKey, лишних запросов нет.
const myDrafts = useMyDrafts(0, 50)
const mySchemaDrafts = useMySchemaDrafts(0, 50)
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
// через size=1 paged запросы. Gated на authenticated.
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
// Outbox/Webhooks badges — light periodic poll. Backend гейтит INTERNAL
// scope: пользователь без нужной роли получает 403 на каждый poll. Гейтим
// на frontend через useHasInternalScope чтобы не спамить console 403'ами
// на каждой странице. Для PUBLIC users badge остаётся пустым (не false-zero).
const hasInternal = useHasInternalScope()
const outboxStats = useOutboxStats(hasInternal)
const webhooks = useWebhookSubscriptions(hasInternal)
// Считаем только actionable-драфты (то же что tab-badge в ReviewsPage):
// record → 'pending', schema → 'pending'|'wip'. APPROVED/REJECTED/WITHDRAWN
// — терминальны, не считаем.
const myRecordPending = (myDrafts.data?.items ?? []).filter(
(r) => classifyRecordStatus(r.status) === 'pending',
).length
const mySchemaPending = (mySchemaDrafts.data?.items ?? []).filter((s) => {
const b = classifySchemaStatus(s.status)
return b === 'pending' || b === 'wip'
}).length
const myDraftsCount = myRecordPending + mySchemaPending
const reviewsCount = reviews.total
// Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует
// нормальную работу, можно увидеть на /outbox. Badge показывает только
// pending (actionable: 'надо разобраться почему queue растёт').
const outboxPending = outboxStats.data?.pending ?? 0
const webhooksActive =
webhooks.data?.filter((w) => w.active).length ?? 0
// Flat list per redesign/compact.html — no section labels, /graph removed
// (now reachable via catalog toolbar "Граф ⇄" button). QA bug #8: «Главная»
// удалён — он вёл на `/` который сразу redirect'нет на /dictionaries; в
// сайдбаре подсветка слетала (active state на /dictionaries, не на /).
// Logo сверху уже даёт home affordance — duplicate nav item только сбивал.
// Order: Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск.
const sections: NavSection[] = [
{
items: [
{
to: '/dictionaries',
label: t('nav.dictionaries'),
icon: Database,
// Без badge: total count словарей — не actionable, только шумит
// (на проде ~40 dicts, цифра не меняется в течение работы юзера).
// Actionable badges остаются: «Мои черновики», «На ревью», «Outbox».
},
{
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,
reviewerOnly: true,
},
{
to: '/outbox',
label: t('nav.outbox'),
icon: Inbox,
badge: outboxPending > 0 ? outboxPending : undefined,
authRequired: true,
internalOnly: true,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
internalOnly: true,
},
// /audit, /outbox, /webhooks — admin observability tools. Backend
// возвращает 403 (audit_internal_required / outbox_internal_required)
// для PUBLIC-scope users. UI больше не показывает links — раньше
// PUBLIC users видели nav entries и кликали → 403 toast (confusing).
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true, internalOnly: true },
{ to: '/search', label: t('nav.search'), icon: Search },
],
},
]
const visibleSections = sections
.map((s) => ({
...s,
items: s.items.filter((i) => {
if (i.authRequired && !canMutate) return false
if (i.reviewerOnly && !isReviewer) return false
if (i.internalOnly && !hasInternal) return false
return true
}),
}))
.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<boolean>(() => {
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 (
<aside
className={cn(
'hidden lg:flex shrink-0 flex-col border-r border-line bg-surface transition-[width] duration-200',
collapsed ? 'w-[56px]' : 'w-[220px]',
)}
>
<SidebarContent
sections={visibleSections}
auth={auth}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/>
</aside>
)
}
/**
* SidebarContent — внутренности (logo + nav + user). Используется в desktop
* rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY.
*/
function SidebarContent({
sections,
auth,
onNavigate,
collapsed = false,
onToggleCollapsed,
}: {
sections: NavSection[]
auth: ReturnType<typeof useAuth>
/** 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 */}
<div
className={cn(
'h-11 flex items-center border-b border-line shrink-0',
collapsed ? 'justify-center px-2' : 'px-5 gap-2',
)}
>
<Link
to="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
onClick={onNavigate}
title="ORDINIS MDM"
>
{/* 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). */}
<svg
width="28"
height="28"
viewBox="0 0 256 256"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="shrink-0 text-accent"
>
<g transform="translate(128 128)">
{/* Outer orbits — line tone. Math: viewBox 256 при render 28px =
* 0.11px/unit. stroke=12 даёт ~1.3px real — чёткая видимая линия. */}
<circle r="96" fill="none" stroke="var(--color-line)" strokeWidth="12" />
<circle r="62" fill="none" stroke="var(--color-line)" strokeWidth="12" />
{/* Outer orbit dots (3) — large accent dots */}
<circle cx="96" cy="0" r="16" fill="currentColor" />
<circle cx="-48" cy="83.14" r="14" fill="currentColor" opacity="0.7" />
<circle cx="-48" cy="-83.14" r="14" fill="currentColor" opacity="0.7" />
{/* Inner orbit dots (3) */}
<circle cx="-62" cy="0" r="13" fill="currentColor" />
<circle cx="31" cy="-53.69" r="11" fill="currentColor" opacity="0.7" />
<circle cx="31" cy="53.69" r="11" fill="currentColor" opacity="0.7" />
{/* Core — solid accent, prominent */}
<circle r="24" fill="currentColor" />
</g>
</svg>
{!collapsed && (
<span className="font-display text-title-md leading-none tracking-[0.22em] text-ink inline-flex items-baseline gap-1.5">
ORDINIS
<span className="text-mute font-normal tracking-[0.32em] text-[0.78em]">MDM</span>
</span>
)}
</Link>
</div>
{/* Nav sections */}
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
{sections.map((section, idx) => (
<div key={idx} className="flex flex-col gap-0.5">
{section.label && !collapsed && (
<div className="text-cap px-3 py-1 text-mute">{section.label}</div>
)}
{section.items.map((item) => (
<SidebarLink key={item.to} {...item} onClick={onNavigate} collapsed={collapsed} />
))}
</div>
))}
</nav>
{/* Footer: Lang toggle + User profile (or Sign-in) + Collapse toggle.
* Moved from TopBar per user feedback: "вход в сайдбар, ru/en в сайдбаре". */}
<SidebarFooter
auth={auth}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
</>
)
}
function SidebarFooter({
auth,
collapsed,
onToggleCollapsed,
}: {
auth: ReturnType<typeof useAuth>
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 && (
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => i18n.changeLanguage(next)}
// h-11 + min-w-11 — DESIGN.md §13 touch target enforcement.
className="h-11 min-w-11 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors inline-flex items-center justify-center"
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
>
{cur.toUpperCase()}
</button>
<div className="ml-auto">
<ThemeSwitch />
</div>
</div>
)}
{/* Auth block: avatar + name (authed) OR Sign-in CTA (anonymous). */}
{auth.isAuthenticated && auth.user?.profile ? (
<AuthedUserBlock auth={auth} collapsed={collapsed} />
) : (
<SignInCta auth={auth} collapsed={collapsed} />
)}
{/* Collapse toggle */}
{onToggleCollapsed && (
<button
type="button"
onClick={onToggleCollapsed}
aria-label={collapsed
? t('sidebar.expand', { defaultValue: 'Развернуть' })
: t('sidebar.collapse', { defaultValue: 'Свернуть' })
}
title={collapsed ? t('sidebar.expand', { defaultValue: 'Развернуть' }) : t('sidebar.collapse', { defaultValue: 'Свернуть' })}
className={cn(
'border-t border-line shrink-0 h-8 flex items-center gap-2 text-cap text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
collapsed ? 'justify-center' : 'px-4',
)}
>
<span aria-hidden>{collapsed ? '' : ''}</span>
{!collapsed && (
<span>{t('sidebar.collapse', { defaultValue: 'Свернуть' })}</span>
)}
</button>
)}
</>
)
}
function AuthedUserBlock({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
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 (
<div
className={cn(
'border-t border-line py-3 flex items-center gap-2 shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={display}
>
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
{display.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">{display}</div>
{profile.email && (
<div className="text-cell text-mute truncate">{String(profile.email)}</div>
)}
</div>
)}
{!collapsed && (
<button
type="button"
onClick={() => auth.signoutRedirect().catch(() => auth.removeUser())}
title={t('auth.logout', { defaultValue: 'Logout' })}
aria-label={t('auth.logout', { defaultValue: 'Logout' })}
// size-11 = 44×44 WCAG AAA per DESIGN.md §13.
className="size-11 rounded-md text-mute hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors shrink-0"
>
<span aria-hidden></span>
</button>
)}
</div>
)
}
function SignInCta({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
collapsed: boolean
}) {
const { t } = useTranslation()
return (
<button
type="button"
onClick={() => auth.signinRedirect()}
className={cn(
'border-t border-line py-2.5 text-cell text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center gap-2 transition-colors shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={t('auth.login', { defaultValue: 'Войти' })}
>
<span aria-hidden></span>
{!collapsed && t('auth.login', { defaultValue: 'Войти' })}
</button>
)
}
/**
* MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается
* через hamburger button в TopBar (<1024px), закрывается на: backdrop click,
* Escape, или клик на любой nav item (auto-close, привычная mobile UX).
*
* <p>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 canReviewSchema = useCanReviewSchema()
const canReviewRecord = useCanReviewRecord()
const isReviewer = canReviewSchema || canReviewRecord
// size=50 (не size=1): см. desktop Sidebar — нужен .items[] для filter
// по статусу (totalElements включает APPROVED/REJECTED).
const myDrafts = useMyDrafts(0, 50)
const mySchemaDrafts = useMySchemaDrafts(0, 50)
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
// Same INTERNAL-scope gate as desktop Sidebar (см. line ~92).
const hasInternal = useHasInternalScope()
const outboxStats = useOutboxStats(hasInternal)
const webhooks = useWebhookSubscriptions(hasInternal)
// Считаем только actionable-драфты (то же что tab-badge в ReviewsPage):
// record → 'pending', schema → 'pending'|'wip'. APPROVED/REJECTED/WITHDRAWN
// — терминальны, не считаем.
const myRecordPending = (myDrafts.data?.items ?? []).filter(
(r) => classifyRecordStatus(r.status) === 'pending',
).length
const mySchemaPending = (mySchemaDrafts.data?.items ?? []).filter((s) => {
const b = classifySchemaStatus(s.status)
return b === 'pending' || b === 'wip'
}).length
const myDraftsCount = myRecordPending + mySchemaPending
const reviewsCount = reviews.total
const outboxPending = outboxStats.data?.pending ?? 0
const webhooksActive =
webhooks.data?.filter((w) => w.active).length ?? 0
// Flat list per redesign — same as desktop Sidebar above. QA bug #8:
// «Главная» удалён (redirect → /dictionaries сбивал active state).
const sections: NavSection[] = [
{
items: [
{
to: '/dictionaries',
label: t('nav.dictionaries'),
icon: Database,
// Без badge: total count словарей — не actionable, только шумит
// (на проде ~40 dicts, цифра не меняется в течение работы юзера).
// Actionable badges остаются: «Мои черновики», «На ревью», «Outbox».
},
{
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,
reviewerOnly: true,
},
{
to: '/outbox',
label: t('nav.outbox'),
icon: Inbox,
badge: outboxPending > 0 ? outboxPending : undefined,
authRequired: true,
internalOnly: true,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
internalOnly: true,
},
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true, internalOnly: true },
{ to: '/search', label: t('nav.search'), icon: Search },
],
},
]
const visibleSections = sections
.map((s) => ({
...s,
items: s.items.filter((i) => {
if (i.authRequired && !canMutate) return false
if (i.reviewerOnly && !isReviewer) return false
if (i.internalOnly && !hasInternal) return false
return true
}),
}))
.filter((s) => s.items.length > 0)
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
side="left"
size="md"
className="!w-[260px] !max-w-[260px] !p-0 !gap-0 flex flex-col bg-surface"
>
{/* Hidden title — Radix DialogContent (which Sheet wraps) требует
DialogTitle для screen reader а11y. Визуально не нужен (logo
block внутри SidebarContent уже служит heading), поэтому
sr-only. Без этого Radix кидает dev-mode warning + screen
reader users не понимают что за dialog открыт. */}
<SheetTitle className="sr-only">
{t('nav.menu', { defaultValue: 'Меню навигации' })}
</SheetTitle>
<SidebarContent sections={visibleSections} auth={auth} onNavigate={onClose} />
</SheetContent>
</Sheet>
)
}
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 (
<Link
to={to}
onClick={onClick}
// Tooltip в collapsed-mode включает label + текущий badge (если есть),
// чтобы юзер видел число при hover на узкой полоске.
title={
collapsed
? typeof label === 'string'
? badge !== undefined
? `${label} (${badge})`
: label
: undefined
: undefined
}
className={cn(
// text-body = 13px workhorse per handoff (см. styles.css @utility).
'group flex items-center rounded-md text-body transition-colors',
collapsed ? 'justify-center h-9 mx-1' : 'gap-2.5 px-3 py-1.5',
active
? 'bg-accent-bg text-accent font-medium'
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
)}
>
{/* Icon + corner-badge overlay в collapsed-mode. Wrapper relative нужен
для absolute badge'а; в expanded-mode иконка без обёртки + badge
справа от label'а как было. */}
{collapsed ? (
<span className="relative inline-flex items-center justify-center">
<Icon size={18} strokeWidth={1.75} className="shrink-0" />
{badge !== undefined && (
<span
aria-hidden="true"
className={cn(
'absolute -top-1.5 -right-1.5 inline-flex items-center justify-center',
'min-w-[1rem] h-4 px-1 rounded-full text-cap font-mono leading-none',
'bg-accent text-on-accent ring-1 ring-surface',
)}
>
{typeof badge === 'number' && badge > 99 ? '99+' : badge}
</span>
)}
</span>
) : (
<>
<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-mono',
active
? 'bg-accent text-on-accent'
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
)}
>
{badge}
</span>
)}
</>
)}
</Link>
)
}