feat(admin-ui): Sidebar 220px + TopBar (Stage 3.1)

This commit is contained in:
Александр Зимин
2026-05-11 08:51:37 +00:00
parent 6d506fed64
commit b2228c2a29
6 changed files with 444 additions and 104 deletions
@@ -0,0 +1,204 @@
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 text-2xs font-display uppercase tracking-[0.10em] text-mute">
{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(
'group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm 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-2xs font-mono',
active
? 'bg-accent text-on-accent'
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
)}
>
{badge}
</span>
)}
</Link>
)
}
@@ -0,0 +1,139 @@
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
import { LanguageSwitch } from '@/ui'
import { AuthBadge } from '@/auth/AuthBadge'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { VersionBadge } from '@/components/version/VersionBadge'
import { SearchInput } from '@/ui/components/search-input'
import { useState } from 'react'
/**
* TopBar — sticky header (h=56) per handoff design.
*
* Structure:
* - Left: dynamic breadcrumb based on current route
* - Center: global SearchInput → submits → navigates to /search?q=
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
*
* Mobile (lg:): на узких экранах sidebar скрывается, breadcrumb sustaiable.
*/
const LANG_OPTIONS = [
{ id: 'ru-RU', label: 'RU' },
{ id: 'en-US', label: 'EN' },
]
export function TopBar() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const [searchValue, setSearchValue] = useState('')
const breadcrumb = useBreadcrumb()
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
const q = searchValue.trim()
if (q.length >= 3) {
navigate({ to: '/search', search: { q } })
}
}
return (
<header className="h-14 shrink-0 border-b border-line bg-surface flex items-center px-4 sm:px-6 gap-3">
{/* Breadcrumb — left */}
<nav className="flex items-center gap-1.5 text-sm min-w-0">
{breadcrumb.map((crumb, i) => {
const isLast = i === breadcrumb.length - 1
return (
<span key={i} className="flex items-center gap-1.5 min-w-0">
{i > 0 && <span className="text-mute text-xs">/</span>}
{crumb.to && !isLast ? (
<Link
to={crumb.to}
className="text-ink-2 hover:text-ink truncate transition-colors"
>
{crumb.label}
</Link>
) : (
<span className={isLast ? 'text-ink font-medium truncate' : 'text-ink-2 truncate'}>
{crumb.label}
</span>
)}
</span>
)
})}
</nav>
{/* Search — center, max-width per handoff */}
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
<SearchInput
placeholder={t('topbar.search.placeholder')}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onClear={() => setSearchValue('')}
aria-label={t('topbar.search.label')}
/>
</form>
{/* Right cluster */}
<div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0">
<a
href="/docs/"
target="_blank"
rel="noreferrer noopener"
className="hidden sm:inline text-xs text-ink-2 hover:text-ink transition-colors"
>
{t('nav.docs')}
</a>
<VersionBadge />
<ThemeSwitch />
<LanguageSwitch
value={i18n.language}
options={LANG_OPTIONS}
onChange={(id) => i18n.changeLanguage(id)}
/>
<AuthBadge />
</div>
</header>
)
}
/**
* Breadcrumb из current route — простой mapping для типичных страниц.
* Сложные breadcrumbs (dict name, record bk) — фактически TopBar shows
* generic, а более детальный crumb рендерит сам route через PageHeader.
*/
function useBreadcrumb(): { label: string; to?: string }[] {
const { t } = useTranslation()
const location = useLocation()
const path = location.pathname
if (path === '/' || path === '') {
return [{ label: t('nav.home') }]
}
const segments = path.split('/').filter(Boolean)
const first = segments[0]
const ROOT_LABELS: Record<string, string> = {
dictionaries: t('nav.dictionaries'),
search: t('nav.search'),
graph: t('nav.graph'),
'my-drafts': t('nav.myDrafts'),
reviews: t('nav.reviews'),
audit: t('nav.audit'),
outbox: t('nav.outbox'),
webhooks: t('nav.webhooks'),
}
const rootLabel = ROOT_LABELS[first] ?? first
const crumbs: { label: string; to?: string }[] = [
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
]
if (segments.length >= 2) {
// Второй сегмент — id / name. Не маппим, оставляем raw.
crumbs.push({ label: decodeURIComponent(segments[1]) })
}
return crumbs
}