@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.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. 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
+ }