Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/Sidebar.tsx
T
Zimin A.N. 0c978bec0c fix(ui): bigger logo + shorter 'Создать' label
Logo: при 26px+тонких stroke (1.5) почти невидим. Bumped:
- width/height 26 → 28
- strokes 1.5 → 5 (визуально ~1.5px при 28px render)
- outer dots r=8 → 16/14 (main + secondaries)
- inner dots r=6.4 → 13/11
- core r=15 → 24
- outer orbit stroke использует --color-line-2 (мягче чем --color-line)

Favicon тоже укрупнён (stroke 2.2, dot r=3, core r=4) — читается в 16px tab.

Label: 'Создать запись' → 'Создать' для dict.action.create (RU + EN).
User feedback: 'Создать запись надо сократь на Создать'.
2026-05-11 23:36:00 +03:00

497 lines
17 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 {
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<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, bolder stroke для видимости при 28px */}
<circle r="96" fill="none" stroke="var(--color-line-2)" strokeWidth="5" />
<circle r="62" fill="none" stroke="var(--color-line-2)" strokeWidth="5" />
{/* 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-[15px] font-semibold 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)}
className="h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
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' })}
className="size-7 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 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 (
<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"
>
<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}
title={collapsed ? (typeof label === 'string' ? 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 size={collapsed ? 18 : 15} strokeWidth={1.75} className="shrink-0" />
{!collapsed && (
<>
<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>
)
}