0c978bec0c
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: 'Создать запись надо сократь на Создать'.
497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|