feat(admin-ui): Sidebar 220px + TopBar (Stage 3.1)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,16 @@ i18n
|
|||||||
'theme.dark': 'Тёмная',
|
'theme.dark': 'Тёмная',
|
||||||
'theme.system': 'Системная',
|
'theme.system': 'Системная',
|
||||||
'nav.docs': 'Руководство',
|
'nav.docs': 'Руководство',
|
||||||
|
'nav.home': 'Главная',
|
||||||
|
'nav.graph': 'Граф связей',
|
||||||
|
'nav.section.workflow': 'Workflow',
|
||||||
|
'nav.section.admin': 'Администрирование',
|
||||||
|
'topbar.search.label': 'Поиск',
|
||||||
|
'topbar.search.placeholder': 'Поиск по справочникам, ⌘K',
|
||||||
|
'graph.title': 'Граф связей справочников',
|
||||||
|
'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.',
|
||||||
|
'graph.comingSoon.title': 'Скоро',
|
||||||
|
'graph.comingSoon.description': 'Visualization через d3-force — 40+ справочников с linked edges. В разработке (Stage 3.3 redesign roadmap).',
|
||||||
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
|
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
|
||||||
'updateBanner.reload': 'Обновить',
|
'updateBanner.reload': 'Обновить',
|
||||||
'updateBanner.docs': 'Что нового',
|
'updateBanner.docs': 'Что нового',
|
||||||
@@ -523,6 +533,16 @@ i18n
|
|||||||
'theme.dark': 'Dark',
|
'theme.dark': 'Dark',
|
||||||
'theme.system': 'System',
|
'theme.system': 'System',
|
||||||
'nav.docs': 'Docs',
|
'nav.docs': 'Docs',
|
||||||
|
'nav.home': 'Home',
|
||||||
|
'nav.graph': 'Relations graph',
|
||||||
|
'nav.section.workflow': 'Workflow',
|
||||||
|
'nav.section.admin': 'Admin',
|
||||||
|
'topbar.search.label': 'Search',
|
||||||
|
'topbar.search.placeholder': 'Search dictionaries, ⌘K',
|
||||||
|
'graph.title': 'Dictionary relations graph',
|
||||||
|
'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.',
|
||||||
|
'graph.comingSoon.title': 'Coming soon',
|
||||||
|
'graph.comingSoon.description': 'd3-force visualization of 40+ dictionaries with linked edges. WIP (Stage 3.3 redesign roadmap).',
|
||||||
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
|
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
|
||||||
'updateBanner.reload': 'Reload',
|
'updateBanner.reload': 'Reload',
|
||||||
'updateBanner.docs': "What's new",
|
'updateBanner.docs': "What's new",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Route as SearchRouteImport } from './routes/search'
|
|||||||
import { Route as ReviewsRouteImport } from './routes/reviews'
|
import { Route as ReviewsRouteImport } from './routes/reviews'
|
||||||
import { Route as OutboxRouteImport } from './routes/outbox'
|
import { Route as OutboxRouteImport } from './routes/outbox'
|
||||||
import { Route as MyDraftsRouteImport } from './routes/my-drafts'
|
import { Route as MyDraftsRouteImport } from './routes/my-drafts'
|
||||||
|
import { Route as GraphRouteImport } from './routes/graph'
|
||||||
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
||||||
import { Route as AuditRouteImport } from './routes/audit'
|
import { Route as AuditRouteImport } from './routes/audit'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
@@ -47,6 +48,11 @@ const MyDraftsRoute = MyDraftsRouteImport.update({
|
|||||||
path: '/my-drafts',
|
path: '/my-drafts',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const GraphRoute = GraphRouteImport.update({
|
||||||
|
id: '/graph',
|
||||||
|
path: '/graph',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DictionariesRoute = DictionariesRouteImport.update({
|
const DictionariesRoute = DictionariesRouteImport.update({
|
||||||
id: '/dictionaries',
|
id: '/dictionaries',
|
||||||
path: '/dictionaries',
|
path: '/dictionaries',
|
||||||
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
'/reviews': typeof ReviewsRoute
|
'/reviews': typeof ReviewsRoute
|
||||||
@@ -100,6 +107,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
'/reviews': typeof ReviewsRoute
|
'/reviews': typeof ReviewsRoute
|
||||||
@@ -114,6 +122,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
'/reviews': typeof ReviewsRoute
|
'/reviews': typeof ReviewsRoute
|
||||||
@@ -130,6 +139,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
| '/reviews'
|
| '/reviews'
|
||||||
@@ -143,6 +153,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
| '/reviews'
|
| '/reviews'
|
||||||
@@ -156,6 +167,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
| '/reviews'
|
| '/reviews'
|
||||||
@@ -171,6 +183,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuditRoute: typeof AuditRoute
|
AuditRoute: typeof AuditRoute
|
||||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||||
|
GraphRoute: typeof GraphRoute
|
||||||
MyDraftsRoute: typeof MyDraftsRoute
|
MyDraftsRoute: typeof MyDraftsRoute
|
||||||
OutboxRoute: typeof OutboxRoute
|
OutboxRoute: typeof OutboxRoute
|
||||||
ReviewsRoute: typeof ReviewsRoute
|
ReviewsRoute: typeof ReviewsRoute
|
||||||
@@ -215,6 +228,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof MyDraftsRouteImport
|
preLoaderRoute: typeof MyDraftsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/graph': {
|
||||||
|
id: '/graph'
|
||||||
|
path: '/graph'
|
||||||
|
fullPath: '/graph'
|
||||||
|
preLoaderRoute: typeof GraphRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/dictionaries': {
|
'/dictionaries': {
|
||||||
id: '/dictionaries'
|
id: '/dictionaries'
|
||||||
path: '/dictionaries'
|
path: '/dictionaries'
|
||||||
@@ -299,6 +319,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuditRoute: AuditRoute,
|
AuditRoute: AuditRoute,
|
||||||
DictionariesRoute: DictionariesRouteWithChildren,
|
DictionariesRoute: DictionariesRouteWithChildren,
|
||||||
|
GraphRoute: GraphRoute,
|
||||||
MyDraftsRoute: MyDraftsRoute,
|
MyDraftsRoute: MyDraftsRoute,
|
||||||
OutboxRoute: OutboxRoute,
|
OutboxRoute: OutboxRoute,
|
||||||
ReviewsRoute: ReviewsRoute,
|
ReviewsRoute: ReviewsRoute,
|
||||||
|
|||||||
@@ -1,118 +1,40 @@
|
|||||||
import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router'
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
import { LanguageSwitch } from '@/ui'
|
import { TopBar } from '@/components/layout/TopBar'
|
||||||
import { AuthBadge } from '@/auth/AuthBadge'
|
|
||||||
import { useCanMutate } from '@/auth/useCanMutate'
|
|
||||||
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
|
||||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root layout (Stage 3.1 design handoff):
|
||||||
|
*
|
||||||
|
* ┌─ Sidebar 220px (lg:flex) ──┬─ main column (flex-1) ────────────┐
|
||||||
|
* │ logo │ UpdateBanner (when applicable) │
|
||||||
|
* │ nav rail с counters │ TopBar h=56 sticky │
|
||||||
|
* │ user profile │ Outlet (route content) │
|
||||||
|
* └────────────────────────────┴───────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Responsive: на <1024px sidebar скрывается (hidden lg:flex). Main grid
|
||||||
|
* растягивается на весь viewport. Mobile hamburger drawer — TODO Stage 3.x.
|
||||||
|
*/
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const LANG_OPTIONS = [
|
|
||||||
{ id: 'ru-RU', label: 'RU' },
|
|
||||||
{ id: 'en-US', label: 'EN' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { t, i18n } = useTranslation()
|
|
||||||
// Гость (anonymous) видит только ссылку на /dictionaries и /search.
|
|
||||||
// Аудит, outbox, webhooks, drafts, reviews — admin-only страницы, требуют
|
|
||||||
// JWT (backend SecurityConfig + admin role). Скрываем из top-nav чтобы
|
|
||||||
// user не получал 401 toast при навигации.
|
|
||||||
const canMutate = useCanMutate()
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col bg-white">
|
<div className="h-screen flex flex-col bg-bg text-ink overflow-hidden">
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<header className="border-b border-regolith bg-white">
|
<div className="flex-1 flex min-h-0">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center flex-wrap gap-3 sm:gap-6">
|
<Sidebar />
|
||||||
<Link to="/" className="font-primary text-base sm:text-lg text-ultramarain">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{t('app.title')}
|
<TopBar />
|
||||||
</Link>
|
<main className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 sm:py-6">
|
||||||
<nav className="flex gap-4 text-sm">
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
<Link
|
|
||||||
to="/dictionaries"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.dictionaries')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/search"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.search')}
|
|
||||||
</Link>
|
|
||||||
{/* Документация — внешняя страница (Docusaurus → ordinis-docs
|
|
||||||
deployment монтируется на /docs ingress'ом). target=_blank
|
|
||||||
чтобы не терять контекст приложения. */}
|
|
||||||
<a
|
|
||||||
href="/docs/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
>
|
|
||||||
{t('nav.docs')}
|
|
||||||
</a>
|
|
||||||
{canMutate && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/audit"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.audit')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/outbox"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.outbox')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/webhooks"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.webhooks')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/reviews"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.reviews')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/my-drafts"
|
|
||||||
className="text-carbon hover:text-ultramarain"
|
|
||||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
|
||||||
>
|
|
||||||
{t('nav.myDrafts')}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
<VersionBadge />
|
|
||||||
<ThemeSwitch />
|
|
||||||
<LanguageSwitch
|
|
||||||
value={i18n.language}
|
|
||||||
options={LANG_OPTIONS}
|
|
||||||
onChange={(id) => i18n.changeLanguage(id)}
|
|
||||||
/>
|
|
||||||
<AuthBadge />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 max-w-6xl mx-auto w-full px-4 sm:px-6 py-4 sm:py-8">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { GitBranch } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/ui'
|
||||||
|
import { EmptyState } from '@/ui/components/empty-state'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Граф связей справочников — d3-force network diagram.
|
||||||
|
*
|
||||||
|
* Stage 3.3 placeholder. Compute graph from {@code useDictionaries() +
|
||||||
|
* useQueries(dependents)}: nodes = dicts, edges = FK refs. Layout —
|
||||||
|
* d3-force с link/charge/center forces. Hover на node → highlight
|
||||||
|
* incoming + outgoing. Click → navigate to dict.
|
||||||
|
*
|
||||||
|
* Dependency: `d3-force` (без full d3, ~30kb gz). Render через canvas
|
||||||
|
* для 40+ nodes performance.
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute('/graph')({
|
||||||
|
component: GraphPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function GraphPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<PageHeader title={t('graph.title')} description={t('graph.description')} />
|
||||||
|
<EmptyState
|
||||||
|
icon={<GitBranch strokeWidth={1.5} />}
|
||||||
|
title={t('graph.comingSoon.title')}
|
||||||
|
description={t('graph.comingSoon.description')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user