@modern-admin/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// Top-level CRUD shell. Composes provider + router + sidebar + content.
|
|
2
|
+
// Sidebar is the shadcn `<Sidebar>` recipe — collapsible to icons on
|
|
3
|
+
// desktop and rendered as a Sheet on mobile via the `useSidebar` context.
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import {
|
|
7
|
+
Avatar,
|
|
8
|
+
AvatarFallback,
|
|
9
|
+
AvatarImage,
|
|
10
|
+
Button,
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuLabel,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
Sidebar,
|
|
18
|
+
SidebarContent,
|
|
19
|
+
SidebarFooter,
|
|
20
|
+
SidebarGroup,
|
|
21
|
+
SidebarGroupLabel,
|
|
22
|
+
SidebarHeader,
|
|
23
|
+
SidebarInset,
|
|
24
|
+
SidebarMenu,
|
|
25
|
+
SidebarMenuButton,
|
|
26
|
+
SidebarMenuItem,
|
|
27
|
+
SidebarProvider,
|
|
28
|
+
cn,
|
|
29
|
+
useSidebar,
|
|
30
|
+
} from '@modern-admin/ui'
|
|
31
|
+
import {
|
|
32
|
+
BookOpen,
|
|
33
|
+
ChevronDown,
|
|
34
|
+
ChevronLeft,
|
|
35
|
+
Database,
|
|
36
|
+
FileText,
|
|
37
|
+
FolderOpen,
|
|
38
|
+
FolderTree,
|
|
39
|
+
Home,
|
|
40
|
+
Image,
|
|
41
|
+
LayoutGrid,
|
|
42
|
+
Loader2,
|
|
43
|
+
LogOut,
|
|
44
|
+
History,
|
|
45
|
+
Mail,
|
|
46
|
+
Menu,
|
|
47
|
+
MessageSquare,
|
|
48
|
+
Package,
|
|
49
|
+
Search,
|
|
50
|
+
Settings,
|
|
51
|
+
ShoppingCart,
|
|
52
|
+
Tag,
|
|
53
|
+
type LucideProps,
|
|
54
|
+
User,
|
|
55
|
+
Users,
|
|
56
|
+
} from 'lucide-react'
|
|
57
|
+
import { getSidebarExtensions } from './extension-registry.js'
|
|
58
|
+
import { useAdminConfig, useCurrentUser, useFeatures, useLogout, useResources } from './hooks.js'
|
|
59
|
+
import { LoginPage } from './pages/login-page.js'
|
|
60
|
+
import type { CurrentUser } from './types.js'
|
|
61
|
+
import { Link, useRoute, useNavigate } from './router.js'
|
|
62
|
+
import { AdminRouterProvider } from './admin-router.js'
|
|
63
|
+
import { useI18n } from './i18n.js'
|
|
64
|
+
import { LanguageSwitcher, ThemeToggle } from './header-controls.js'
|
|
65
|
+
import { NotifyToaster } from './notify.js'
|
|
66
|
+
import { DialogsProvider } from './dialogs.js'
|
|
67
|
+
import { HotkeyRegistryProvider } from './hotkey-registry.js'
|
|
68
|
+
import { HotkeyHelpButton } from './hotkey-help.js'
|
|
69
|
+
import type { ResourceJSON } from './types.js'
|
|
70
|
+
import { AiAssistantWidget } from './components/ai-assistant-widget.js'
|
|
71
|
+
import { GlobalSearchDialog } from './components/global-search-dialog.js'
|
|
72
|
+
import { useHotkey } from './use-hotkey.js'
|
|
73
|
+
|
|
74
|
+
// ─── Icon registry ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
type IconComponent = React.ComponentType<LucideProps>
|
|
77
|
+
|
|
78
|
+
const ICON_MAP: Record<string, IconComponent> = {
|
|
79
|
+
BookOpen,
|
|
80
|
+
Database,
|
|
81
|
+
FileText,
|
|
82
|
+
FolderOpen,
|
|
83
|
+
FolderTree,
|
|
84
|
+
Home,
|
|
85
|
+
Image,
|
|
86
|
+
LayoutGrid,
|
|
87
|
+
Mail,
|
|
88
|
+
MessageSquare,
|
|
89
|
+
Package,
|
|
90
|
+
Settings,
|
|
91
|
+
ShoppingCart,
|
|
92
|
+
Tag,
|
|
93
|
+
Users,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function NavIcon({ name, className }: { name?: string; className?: string }): React.ReactElement {
|
|
97
|
+
const Icon: IconComponent = (name && ICON_MAP[name]) || Database
|
|
98
|
+
return <Icon className={className} />
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
interface NavGroup {
|
|
104
|
+
label: string
|
|
105
|
+
resources: ResourceJSON[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildNavGroups(resources: ResourceJSON[]): {
|
|
109
|
+
groups: NavGroup[]
|
|
110
|
+
ungrouped: ResourceJSON[]
|
|
111
|
+
} {
|
|
112
|
+
const groupMap = new Map<string, ResourceJSON[]>()
|
|
113
|
+
const ungrouped: ResourceJSON[] = []
|
|
114
|
+
|
|
115
|
+
for (const r of resources) {
|
|
116
|
+
if (r.navigation === null) continue // explicitly hidden
|
|
117
|
+
const group = r.navigation?.name ?? r.navigation?.group
|
|
118
|
+
if (group) {
|
|
119
|
+
const list = groupMap.get(group) ?? []
|
|
120
|
+
list.push(r)
|
|
121
|
+
groupMap.set(group, list)
|
|
122
|
+
} else {
|
|
123
|
+
ungrouped.push(r)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const groups: NavGroup[] = Array.from(groupMap.entries()).map(([label, rs]) => ({ label, resources: rs }))
|
|
128
|
+
return { groups, ungrouped }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Sidebar group collapse persistence ───────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const SIDEBAR_GROUPS_KEY = 'sidebar:groups:collapsed'
|
|
134
|
+
|
|
135
|
+
function loadCollapsedGroups(): Set<string> {
|
|
136
|
+
if (typeof localStorage === 'undefined') return new Set()
|
|
137
|
+
try {
|
|
138
|
+
const raw = localStorage.getItem(SIDEBAR_GROUPS_KEY)
|
|
139
|
+
return raw ? new Set(JSON.parse(raw) as string[]) : new Set()
|
|
140
|
+
} catch {
|
|
141
|
+
return new Set()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function saveCollapsedGroups(collapsed: Set<string>): void {
|
|
146
|
+
if (typeof localStorage === 'undefined') return
|
|
147
|
+
try {
|
|
148
|
+
localStorage.setItem(SIDEBAR_GROUPS_KEY, JSON.stringify([...collapsed]))
|
|
149
|
+
} catch { /* quota / private mode — ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isResourceActive(route: ReturnType<typeof useRoute>, resourceId: string): boolean {
|
|
153
|
+
return (
|
|
154
|
+
(route.name === 'list' || route.name === 'show' || route.name === 'edit' || route.name === 'new') &&
|
|
155
|
+
'resourceId' in route &&
|
|
156
|
+
route.resourceId === resourceId
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ResourceMenuItem({
|
|
161
|
+
resource,
|
|
162
|
+
showId,
|
|
163
|
+
}: {
|
|
164
|
+
resource: ResourceJSON
|
|
165
|
+
showId: boolean
|
|
166
|
+
}): React.ReactElement {
|
|
167
|
+
const route = useRoute()
|
|
168
|
+
const active = isResourceActive(route, resource.id)
|
|
169
|
+
const hasAlias = resource.name !== resource.id
|
|
170
|
+
const withId = showId && hasAlias
|
|
171
|
+
const tooltip = withId ? `${resource.name} (${resource.id})` : resource.name
|
|
172
|
+
return (
|
|
173
|
+
<SidebarMenuItem>
|
|
174
|
+
<SidebarMenuButton asChild isActive={active} tooltip={tooltip}>
|
|
175
|
+
<Link to={{ name: 'list', resourceId: resource.id }}>
|
|
176
|
+
<NavIcon name={resource.navigation?.icon} />
|
|
177
|
+
<span className="min-w-0 flex-1 truncate">
|
|
178
|
+
{resource.name}
|
|
179
|
+
{withId && (
|
|
180
|
+
<span className="ml-0.5 text-xs opacity-60"> ({resource.id})</span>
|
|
181
|
+
)}
|
|
182
|
+
</span>
|
|
183
|
+
</Link>
|
|
184
|
+
</SidebarMenuButton>
|
|
185
|
+
</SidebarMenuItem>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Desktop collapse pill — round chevron on the right edge of the sidebar.
|
|
190
|
+
// Sits half-outside the sidebar (translate-x-1/2) and rotates the chevron
|
|
191
|
+
// when the sidebar is in icon/collapsed state. Hidden on mobile (the
|
|
192
|
+
// header burger handles that).
|
|
193
|
+
function SidebarCollapseToggle(): React.ReactElement {
|
|
194
|
+
const { state, toggleSidebar } = useSidebar()
|
|
195
|
+
const collapsed = state === 'collapsed'
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={toggleSidebar}
|
|
200
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
201
|
+
className="absolute right-0 top-24 z-50 hidden h-5 w-5 translate-x-1/2 items-center justify-center rounded-full border border-border bg-card shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground md:flex"
|
|
202
|
+
>
|
|
203
|
+
<ChevronLeft
|
|
204
|
+
className="size-3 transition-transform duration-300"
|
|
205
|
+
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
206
|
+
/>
|
|
207
|
+
</button>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Sidebar ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
function AppSidebar({ showResourceIds }: { showResourceIds: boolean }): React.ReactElement {
|
|
214
|
+
const resources = useResources()
|
|
215
|
+
const features = useFeatures()
|
|
216
|
+
const { t } = useI18n()
|
|
217
|
+
const route = useRoute()
|
|
218
|
+
const { data: config } = useAdminConfig()
|
|
219
|
+
const appName = config?.branding?.companyName ?? t('common:appName')
|
|
220
|
+
const { groups, ungrouped } = React.useMemo(() => buildNavGroups(resources), [resources])
|
|
221
|
+
const { isMobile, setOpenMobile, state } = useSidebar()
|
|
222
|
+
|
|
223
|
+
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(loadCollapsedGroups)
|
|
224
|
+
const toggleGroup = React.useCallback((label: string): void => {
|
|
225
|
+
setCollapsedGroups((prev) => {
|
|
226
|
+
const next = new Set(prev)
|
|
227
|
+
if (next.has(label)) next.delete(label)
|
|
228
|
+
else next.add(label)
|
|
229
|
+
saveCollapsedGroups(next)
|
|
230
|
+
return next
|
|
231
|
+
})
|
|
232
|
+
}, [])
|
|
233
|
+
|
|
234
|
+
// Auto-close the mobile drawer whenever the route changes (link tap).
|
|
235
|
+
const routeKey = `${route.name}:${'resourceId' in route ? route.resourceId : ''}:${'recordId' in route ? route.recordId : ''}`
|
|
236
|
+
React.useEffect(() => {
|
|
237
|
+
if (isMobile) setOpenMobile(false)
|
|
238
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
239
|
+
}, [routeKey])
|
|
240
|
+
|
|
241
|
+
const homeActive = route.name === 'home'
|
|
242
|
+
const auditActive = route.name === 'audit-log'
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Sidebar collapsible="icon">
|
|
246
|
+
<SidebarHeader className="h-12 flex-row items-center gap-2 border-b border-border px-3 py-0 sm:h-14 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
|
|
247
|
+
<Database className="size-4 shrink-0 text-primary" />
|
|
248
|
+
<span className="truncate text-sm font-semibold group-data-[collapsible=icon]:hidden">
|
|
249
|
+
{appName}
|
|
250
|
+
</span>
|
|
251
|
+
</SidebarHeader>
|
|
252
|
+
<SidebarContent>
|
|
253
|
+
<SidebarGroup>
|
|
254
|
+
<SidebarMenu>
|
|
255
|
+
<SidebarMenuItem>
|
|
256
|
+
<SidebarMenuButton asChild isActive={homeActive} tooltip={t('common:home')}>
|
|
257
|
+
<Link to={{ name: 'home' }}>
|
|
258
|
+
<Home />
|
|
259
|
+
<span>{t('common:home')}</span>
|
|
260
|
+
</Link>
|
|
261
|
+
</SidebarMenuButton>
|
|
262
|
+
</SidebarMenuItem>
|
|
263
|
+
{features.auditLog && (
|
|
264
|
+
<SidebarMenuItem>
|
|
265
|
+
<SidebarMenuButton asChild isActive={auditActive} tooltip={t('audit:title')}>
|
|
266
|
+
<Link to={{ name: 'audit-log' }}>
|
|
267
|
+
<History />
|
|
268
|
+
<span>{t('audit:title')}</span>
|
|
269
|
+
</Link>
|
|
270
|
+
</SidebarMenuButton>
|
|
271
|
+
</SidebarMenuItem>
|
|
272
|
+
)}
|
|
273
|
+
{getSidebarExtensions()
|
|
274
|
+
.filter((ext) => !ext.featureGate || !!(features as unknown as Record<string, unknown>)[ext.featureGate])
|
|
275
|
+
.map((ext) => {
|
|
276
|
+
const extActive =
|
|
277
|
+
route.name === 'extension' && route.key === ext.extensionKey
|
|
278
|
+
return (
|
|
279
|
+
<SidebarMenuItem key={ext.key}>
|
|
280
|
+
<SidebarMenuButton
|
|
281
|
+
asChild
|
|
282
|
+
isActive={extActive}
|
|
283
|
+
tooltip={ext.label}
|
|
284
|
+
>
|
|
285
|
+
<Link to={{ name: 'extension', key: ext.extensionKey }}>
|
|
286
|
+
<ext.icon />
|
|
287
|
+
<span>{ext.label}</span>
|
|
288
|
+
</Link>
|
|
289
|
+
</SidebarMenuButton>
|
|
290
|
+
</SidebarMenuItem>
|
|
291
|
+
)
|
|
292
|
+
})}
|
|
293
|
+
{ungrouped.map((r) => (
|
|
294
|
+
<ResourceMenuItem key={r.id} resource={r} showId={showResourceIds} />
|
|
295
|
+
))}
|
|
296
|
+
</SidebarMenu>
|
|
297
|
+
</SidebarGroup>
|
|
298
|
+
|
|
299
|
+
{groups.map((group) => {
|
|
300
|
+
// In icon mode the label is hidden (opacity-0) and items show as
|
|
301
|
+
// icon-only tooltips — always show items regardless of collapse state.
|
|
302
|
+
const isOpen = state === 'collapsed' || !collapsedGroups.has(group.label)
|
|
303
|
+
return (
|
|
304
|
+
<SidebarGroup key={group.label}>
|
|
305
|
+
<SidebarGroupLabel asChild>
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
className="w-full cursor-pointer justify-between hover:bg-accent hover:text-accent-foreground"
|
|
309
|
+
onClick={() => toggleGroup(group.label)}
|
|
310
|
+
>
|
|
311
|
+
{group.label}
|
|
312
|
+
<ChevronDown
|
|
313
|
+
className={cn(
|
|
314
|
+
'size-4 shrink-0 transition-transform duration-200',
|
|
315
|
+
!isOpen && '-rotate-90',
|
|
316
|
+
)}
|
|
317
|
+
/>
|
|
318
|
+
</button>
|
|
319
|
+
</SidebarGroupLabel>
|
|
320
|
+
{isOpen && (
|
|
321
|
+
<SidebarMenu>
|
|
322
|
+
{group.resources.map((r) => (
|
|
323
|
+
<ResourceMenuItem key={r.id} resource={r} showId={showResourceIds} />
|
|
324
|
+
))}
|
|
325
|
+
</SidebarMenu>
|
|
326
|
+
)}
|
|
327
|
+
</SidebarGroup>
|
|
328
|
+
)
|
|
329
|
+
})}
|
|
330
|
+
</SidebarContent>
|
|
331
|
+
<SidebarFooter />
|
|
332
|
+
<SidebarCollapseToggle />
|
|
333
|
+
</Sidebar>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Header ───────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
function userInitials(user: CurrentUser): string {
|
|
340
|
+
const source = user.name?.trim() || user.email?.trim() || user.id
|
|
341
|
+
const parts = source.split(/\s+|[._@-]/).filter(Boolean)
|
|
342
|
+
if (parts.length === 0) return '?'
|
|
343
|
+
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()
|
|
344
|
+
return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function UserMenu({ user }: { user: CurrentUser }): React.ReactElement {
|
|
348
|
+
const { t } = useI18n()
|
|
349
|
+
const logout = useLogout()
|
|
350
|
+
const navigate = useNavigate()
|
|
351
|
+
const features = useFeatures()
|
|
352
|
+
const initials = userInitials(user)
|
|
353
|
+
const display = user.name || user.email || user.id
|
|
354
|
+
// First section advertised by the backend — the entry in the user menu
|
|
355
|
+
// jumps straight there. When every section is disabled (no api-keys,
|
|
356
|
+
// no webhooks, no ai-assistant) the Settings entry is hidden entirely.
|
|
357
|
+
const firstSettingsSection: 'api-keys' | 'webhooks' | 'ai-assistant' | null =
|
|
358
|
+
features.apiKeys ? 'api-keys'
|
|
359
|
+
: features.webhooks ? 'webhooks'
|
|
360
|
+
: features.aiAssistant ? 'ai-assistant'
|
|
361
|
+
: null
|
|
362
|
+
return (
|
|
363
|
+
<DropdownMenu>
|
|
364
|
+
<DropdownMenuTrigger asChild>
|
|
365
|
+
<Button
|
|
366
|
+
variant="ghost"
|
|
367
|
+
className="h-9 gap-2 px-2"
|
|
368
|
+
aria-label={display}
|
|
369
|
+
>
|
|
370
|
+
<Avatar className="size-7">
|
|
371
|
+
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={display} />}
|
|
372
|
+
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
|
373
|
+
</Avatar>
|
|
374
|
+
<span className="hidden max-w-[10rem] truncate text-sm sm:inline">{display}</span>
|
|
375
|
+
</Button>
|
|
376
|
+
</DropdownMenuTrigger>
|
|
377
|
+
<DropdownMenuContent align="end" sideOffset={8} className="w-56 p-1">
|
|
378
|
+
<DropdownMenuLabel className="flex items-center gap-2 px-3 py-2">
|
|
379
|
+
<Avatar className="size-8">
|
|
380
|
+
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={display} />}
|
|
381
|
+
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
|
382
|
+
</Avatar>
|
|
383
|
+
<div className="flex min-w-0 flex-col">
|
|
384
|
+
<span className="truncate text-sm font-medium">{display}</span>
|
|
385
|
+
{user.email && user.email !== display && (
|
|
386
|
+
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
|
387
|
+
)}
|
|
388
|
+
{user.role && (
|
|
389
|
+
<span className="truncate text-xs uppercase tracking-wide text-muted-foreground">
|
|
390
|
+
{user.role}
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</DropdownMenuLabel>
|
|
395
|
+
{firstSettingsSection && (
|
|
396
|
+
<>
|
|
397
|
+
<DropdownMenuSeparator />
|
|
398
|
+
<DropdownMenuItem
|
|
399
|
+
className="gap-3 px-3 py-2"
|
|
400
|
+
onSelect={(e) => {
|
|
401
|
+
e.preventDefault()
|
|
402
|
+
navigate({ name: 'settings', section: firstSettingsSection })
|
|
403
|
+
}}
|
|
404
|
+
>
|
|
405
|
+
<Settings className="size-4 text-muted-foreground" />
|
|
406
|
+
<span>{t('settings:menuItem')}</span>
|
|
407
|
+
</DropdownMenuItem>
|
|
408
|
+
</>
|
|
409
|
+
)}
|
|
410
|
+
<DropdownMenuSeparator />
|
|
411
|
+
<DropdownMenuItem
|
|
412
|
+
className="gap-3 px-3 py-2"
|
|
413
|
+
disabled={logout.isPending}
|
|
414
|
+
onSelect={(e) => {
|
|
415
|
+
e.preventDefault()
|
|
416
|
+
logout.mutate()
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
<LogOut className="size-4 text-muted-foreground" />
|
|
420
|
+
<span>{t('auth:logout')}</span>
|
|
421
|
+
</DropdownMenuItem>
|
|
422
|
+
</DropdownMenuContent>
|
|
423
|
+
</DropdownMenu>
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Header trigger that opens the cross-resource command palette. Renders
|
|
428
|
+
* as a full-width "search" button on desktop (with a ⌘K hint) and collapses
|
|
429
|
+
* to an icon-only button on mobile. */
|
|
430
|
+
function GlobalSearchTrigger({
|
|
431
|
+
onOpen,
|
|
432
|
+
}: {
|
|
433
|
+
onOpen(): void
|
|
434
|
+
}): React.ReactElement {
|
|
435
|
+
const { t } = useI18n()
|
|
436
|
+
// Detect macOS so the keyboard hint reads ⌘K vs Ctrl+K. Falls back to
|
|
437
|
+
// platform-agnostic mod key on SSR / unknown UAs.
|
|
438
|
+
const isMac = React.useMemo(() => {
|
|
439
|
+
if (typeof navigator === 'undefined') return false
|
|
440
|
+
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent)
|
|
441
|
+
}, [])
|
|
442
|
+
return (
|
|
443
|
+
<>
|
|
444
|
+
<Button
|
|
445
|
+
variant="outline"
|
|
446
|
+
size="sm"
|
|
447
|
+
onClick={onOpen}
|
|
448
|
+
aria-label={t('globalSearch:title')}
|
|
449
|
+
className="hidden h-9 w-full max-w-xs justify-start gap-2 px-3 text-muted-foreground sm:inline-flex"
|
|
450
|
+
>
|
|
451
|
+
<Search className="size-4" />
|
|
452
|
+
<span className="flex-1 truncate text-left text-sm">
|
|
453
|
+
{t('globalSearch:trigger')}
|
|
454
|
+
</span>
|
|
455
|
+
<kbd className="pointer-events-none ml-2 inline-flex h-5 select-none items-center gap-0.5 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
|
456
|
+
{isMac ? <span aria-hidden="true">⌘</span> : <span aria-hidden="true">Ctrl</span>}
|
|
457
|
+
<span aria-hidden="true">K</span>
|
|
458
|
+
</kbd>
|
|
459
|
+
</Button>
|
|
460
|
+
<Button
|
|
461
|
+
variant="ghost"
|
|
462
|
+
size="icon"
|
|
463
|
+
onClick={onOpen}
|
|
464
|
+
aria-label={t('globalSearch:title')}
|
|
465
|
+
className="sm:hidden"
|
|
466
|
+
>
|
|
467
|
+
<Search className="size-4" />
|
|
468
|
+
</Button>
|
|
469
|
+
</>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function Header({ user }: { user: CurrentUser | null }): React.ReactElement {
|
|
474
|
+
const { t } = useI18n()
|
|
475
|
+
const { setOpenMobile } = useSidebar()
|
|
476
|
+
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
477
|
+
// Cmd+K on macOS, Ctrl+K elsewhere — both expressed via `mod`. Allowed in
|
|
478
|
+
// input contexts so users can pop the palette while typing in any field.
|
|
479
|
+
useHotkey('mod+k', () => setSearchOpen((prev) => !prev), {
|
|
480
|
+
allowInInput: true,
|
|
481
|
+
description: t('globalSearch:hotkey'),
|
|
482
|
+
group: t('common:search'),
|
|
483
|
+
})
|
|
484
|
+
return (
|
|
485
|
+
<header className="sticky top-0 z-30 flex h-12 shrink-0 items-center gap-1 border-b border-border bg-card px-2 sm:h-14 sm:gap-3 sm:px-6">
|
|
486
|
+
<Button
|
|
487
|
+
variant="ghost"
|
|
488
|
+
size="icon"
|
|
489
|
+
className="md:hidden"
|
|
490
|
+
onClick={() => setOpenMobile(true)}
|
|
491
|
+
aria-label={t('common:openMenu')}
|
|
492
|
+
>
|
|
493
|
+
<Menu className="size-5" />
|
|
494
|
+
</Button>
|
|
495
|
+
<GlobalSearchTrigger onOpen={() => setSearchOpen(true)} />
|
|
496
|
+
<div className="ml-auto flex items-center gap-1">
|
|
497
|
+
<HotkeyHelpButton />
|
|
498
|
+
<LanguageSwitcher />
|
|
499
|
+
<ThemeToggle />
|
|
500
|
+
{user ? (
|
|
501
|
+
<UserMenu user={user} />
|
|
502
|
+
) : (
|
|
503
|
+
<Button variant="ghost" size="icon" disabled aria-label={t('auth:login')}>
|
|
504
|
+
<User className="size-4 opacity-50" />
|
|
505
|
+
</Button>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
<GlobalSearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
|
509
|
+
</header>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─── AdminApp ─────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
export interface AdminAppProps {
|
|
516
|
+
/** Optional helper line shown under the title on the login screen — e.g.
|
|
517
|
+
* demo credentials. */
|
|
518
|
+
loginHint?: React.ReactNode
|
|
519
|
+
/**
|
|
520
|
+
* URL prefix where the SPA is mounted (e.g. `/admin`). Injected
|
|
521
|
+
* automatically from `window.__MODERN_ADMIN__.basePath` by the standalone
|
|
522
|
+
* bundle. Drives the router basepath so all navigation and deep-link
|
|
523
|
+
* refreshes stay under the correct prefix. Defaults to `''` (root mount).
|
|
524
|
+
*/
|
|
525
|
+
basePath?: string
|
|
526
|
+
/**
|
|
527
|
+
* When true, the sidebar resource list appends the raw resource id in
|
|
528
|
+
* parentheses next to the localized label (e.g. "Posts (posts)") when
|
|
529
|
+
* the label differs from the id. Defaults to `false` to keep the
|
|
530
|
+
* sidebar tidy. The home-page resource tiles and selector dropdowns
|
|
531
|
+
* (chart builder, etc.) always render both — they are not affected.
|
|
532
|
+
*/
|
|
533
|
+
showSidebarResourceIds?: boolean
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function FullscreenSpinner(): React.ReactElement {
|
|
537
|
+
const { t } = useI18n()
|
|
538
|
+
return (
|
|
539
|
+
<div
|
|
540
|
+
role="status"
|
|
541
|
+
aria-busy="true"
|
|
542
|
+
aria-live="polite"
|
|
543
|
+
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6"
|
|
544
|
+
>
|
|
545
|
+
<div className="flex items-center gap-3">
|
|
546
|
+
<span className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-primary/20">
|
|
547
|
+
<Database className="size-6 text-primary" />
|
|
548
|
+
</span>
|
|
549
|
+
<span className="text-xl font-semibold tracking-tight">
|
|
550
|
+
{t('common:appName')}
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
554
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
555
|
+
<span>{t('common:loading')}</span>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// `ShellLayout` is the rootRoute component supplied to TSR via context.
|
|
562
|
+
// `useCurrentUser()` here is safe — provider/query state is set up upstream
|
|
563
|
+
// in `AdminApp`, and by the time this layout renders the user is guaranteed
|
|
564
|
+
// to be authenticated (otherwise `AdminApp` short-circuits to `<LoginPage/>`).
|
|
565
|
+
function ShellLayout({
|
|
566
|
+
children,
|
|
567
|
+
showSidebarResourceIds,
|
|
568
|
+
}: {
|
|
569
|
+
children: React.ReactNode
|
|
570
|
+
showSidebarResourceIds: boolean
|
|
571
|
+
}): React.ReactElement {
|
|
572
|
+
const { user } = useCurrentUser()
|
|
573
|
+
return (
|
|
574
|
+
<HotkeyRegistryProvider>
|
|
575
|
+
<DialogsProvider>
|
|
576
|
+
<SidebarProvider>
|
|
577
|
+
<AppSidebar showResourceIds={showSidebarResourceIds} />
|
|
578
|
+
{/* `h-svh` on SidebarInset is the key to the scroll layout: it
|
|
579
|
+
constrains the inset to exactly the viewport height, which lets
|
|
580
|
+
the inner `<main overflow-auto>` (flex-1) actually scroll
|
|
581
|
+
internally instead of letting the whole page scroll. Without
|
|
582
|
+
this, `min-h-svh` made the inset grow with content, the main
|
|
583
|
+
never overflowed, and `position: sticky` inside it had no
|
|
584
|
+
scrolling ancestor to pin against.
|
|
585
|
+
|
|
586
|
+
`overflow-hidden` is also required: without it, the inner
|
|
587
|
+
`<main overflow-auto>`'s clipped descendant layout boxes still
|
|
588
|
+
propagate into the SidebarInset's (and document's) scrollHeight,
|
|
589
|
+
producing a second, page-level scrollbar that scrolls the whole
|
|
590
|
+
SidebarInset out of view into empty background. */}
|
|
591
|
+
<SidebarInset className="h-svh min-w-0 overflow-hidden">
|
|
592
|
+
<Header user={user} />
|
|
593
|
+
{/* Padding has no `pb`: sticky footers (e.g. list-page paginator)
|
|
594
|
+
must be able to reach the viewport bottom, but `position: sticky`
|
|
595
|
+
is bounded by its containing block, which lives inside `<main>`'s
|
|
596
|
+
padding. With `pb-0`, the sticky element can extend all the way
|
|
597
|
+
down. Children that don't have a sticky footer add their own
|
|
598
|
+
bottom spacing via the `pb-4 sm:pb-6` class. */}
|
|
599
|
+
<main className="min-h-0 min-w-0 flex-1 overflow-auto px-2 pt-2 sm:px-6 sm:pt-6">
|
|
600
|
+
{children}
|
|
601
|
+
</main>
|
|
602
|
+
<AiAssistantWidget />
|
|
603
|
+
</SidebarInset>
|
|
604
|
+
<NotifyToaster />
|
|
605
|
+
</SidebarProvider>
|
|
606
|
+
</DialogsProvider>
|
|
607
|
+
</HotkeyRegistryProvider>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function AdminApp({
|
|
612
|
+
loginHint,
|
|
613
|
+
basePath,
|
|
614
|
+
showSidebarResourceIds,
|
|
615
|
+
}: AdminAppProps = {}): React.ReactElement {
|
|
616
|
+
const { user, isLoading, isAuthenticated } = useCurrentUser()
|
|
617
|
+
const showIds = showSidebarResourceIds ?? false
|
|
618
|
+
// Capture the option in a stable layout component so the router doesn't
|
|
619
|
+
// remount the whole shell whenever the prop reference changes.
|
|
620
|
+
const Layout = React.useMemo(
|
|
621
|
+
() =>
|
|
622
|
+
function ConfiguredShellLayout({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
623
|
+
return <ShellLayout showSidebarResourceIds={showIds}>{children}</ShellLayout>
|
|
624
|
+
},
|
|
625
|
+
[showIds],
|
|
626
|
+
)
|
|
627
|
+
if (isLoading) return <FullscreenSpinner />
|
|
628
|
+
if (!isAuthenticated || !user) return <LoginPage hint={loginHint} />
|
|
629
|
+
return <AdminRouterProvider ShellLayout={Layout} basepath={basePath ?? ''} />
|
|
630
|
+
}
|