@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,1096 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import Image from 'next/image'
5
+ import { Separator } from '../primitives/separator'
6
+ import { FlashMessages } from './FlashMessages'
7
+ import { usePathname } from 'next/navigation'
8
+ import { apiCall } from './utils/apiCall'
9
+ import { LanguageSwitcher } from '../frontend/LanguageSwitcher'
10
+ import { ThemeToggle } from '../theme/ThemeToggle'
11
+ import { LastOperationBanner } from './operations/LastOperationBanner'
12
+ import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
13
+ import { PartialIndexBanner } from './indexes/PartialIndexBanner'
14
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
15
+ import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
16
+
17
+ export type AppShellProps = {
18
+ productName?: string
19
+ email?: string
20
+ groups: {
21
+ id?: string
22
+ name: string
23
+ defaultName?: string
24
+ items: {
25
+ href: string
26
+ title: string
27
+ defaultTitle?: string
28
+ icon?: React.ReactNode
29
+ enabled?: boolean
30
+ hidden?: boolean
31
+ children?: {
32
+ href: string
33
+ title: string
34
+ defaultTitle?: string
35
+ icon?: React.ReactNode
36
+ enabled?: boolean
37
+ hidden?: boolean
38
+ }[]
39
+ }[]
40
+ }[]
41
+ children: React.ReactNode
42
+ rightHeaderSlot?: React.ReactNode
43
+ sidebarCollapsedDefault?: boolean
44
+ currentTitle?: string
45
+ breadcrumb?: Array<{ label: string; href?: string }>
46
+ // Optional: full admin nav API to refresh sidebar client-side
47
+ adminNavApi?: string
48
+ version?: string
49
+ }
50
+
51
+ type Breadcrumb = Array<{ label: string; href?: string }>
52
+
53
+ type SidebarCustomizationDraft = {
54
+ order: string[]
55
+ groupLabels: Record<string, string>
56
+ itemLabels: Record<string, string>
57
+ hiddenItemIds: Record<string, boolean>
58
+ }
59
+
60
+ type SidebarGroup = AppShellProps['groups'][number]
61
+ type SidebarItem = SidebarGroup['items'][number]
62
+ type SidebarRoleTarget = { id: string; name: string; hasPreference: boolean }
63
+
64
+ function resolveGroupKey(group: SidebarGroup): string {
65
+ if (group.id && group.id.length) return group.id
66
+ if (group.defaultName && group.defaultName.length) return slugifySidebarId(group.defaultName)
67
+ return slugifySidebarId(group.name)
68
+ }
69
+
70
+ const HeaderContext = React.createContext<{
71
+ setBreadcrumb: (b?: Breadcrumb) => void
72
+ setTitle: (t?: string) => void
73
+ } | null>(null)
74
+
75
+ export function ApplyBreadcrumb({ breadcrumb, title, titleKey }: { breadcrumb?: Array<{ label: string; href?: string; labelKey?: string }>; title?: string; titleKey?: string }) {
76
+ const ctx = React.useContext(HeaderContext)
77
+ const t = useT()
78
+ const resolvedBreadcrumb = React.useMemo<Breadcrumb | undefined>(() => {
79
+ if (!breadcrumb) return undefined
80
+ return breadcrumb.map(({ label, labelKey, href }) => {
81
+ const translated = labelKey ? t(labelKey) : undefined
82
+ const finalLabel = translated && translated !== labelKey ? translated : label
83
+ return {
84
+ href,
85
+ label: finalLabel,
86
+ }
87
+ })
88
+ }, [breadcrumb, t])
89
+ const resolvedTitle = React.useMemo(() => {
90
+ if (!titleKey) return title
91
+ const translated = t(titleKey)
92
+ if (translated && translated !== titleKey) return translated
93
+ return title
94
+ }, [titleKey, title, t])
95
+ React.useEffect(() => {
96
+ ctx?.setBreadcrumb(resolvedBreadcrumb)
97
+ if (resolvedTitle !== undefined) ctx?.setTitle(resolvedTitle)
98
+ }, [ctx, resolvedBreadcrumb, resolvedTitle])
99
+ return null
100
+ }
101
+
102
+ const DefaultIcon = (
103
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
104
+ <path d="M8 6h13M8 12h13M8 18h13"/>
105
+ <path d="M3 6h.01M3 12h.01M3 18h.01"/>
106
+ </svg>
107
+ )
108
+
109
+ // DataTable icon used for dynamic custom entity records links
110
+ const DataTableIcon = (
111
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
112
+ <rect x="3" y="4" width="18" height="16" rx="2" ry="2"/>
113
+ <line x1="3" y1="8" x2="21" y2="8"/>
114
+ <line x1="9" y1="8" x2="9" y2="20"/>
115
+ <line x1="15" y1="8" x2="15" y2="20"/>
116
+ </svg>
117
+ )
118
+
119
+ const CustomizeIcon = (
120
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
121
+ <circle cx="12" cy="12" r="3" />
122
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.05.05a2 2 0 1 1-2.83 2.83l-.05-.05A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82l-.05.05a2 2 0 1 1-2.83-2.83l.05-.05A1.65 1.65 0 0 0 9 15a1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.05.05a2 2 0 1 1-2.83-2.83l.05-.05A1.65 1.65 0 0 0 4.6 9 1.65 1.65 0 0 0 4 8a1.65 1.65 0 0 0-.6-1.82l-.05-.05a2 2 0 1 1 2.83-2.83l.05.05A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82l.05-.05a2 2 0 1 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 15 9a1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l.05-.05a2 2 0 1 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 19.4 15z" />
123
+ </svg>
124
+ )
125
+
126
+ function Chevron({ open }: { open: boolean }) {
127
+ return (
128
+ <svg className={`transition-transform ${open ? 'rotate-180' : ''}`} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
129
+ )
130
+ }
131
+
132
+ export function AppShell({ productName, email, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, adminNavApi, version }: AppShellProps) {
133
+ const pathname = usePathname()
134
+ const t = useT()
135
+ const locale = useLocale()
136
+ const resolvedProductName = productName ?? t('appShell.productName')
137
+ const [mobileOpen, setMobileOpen] = React.useState(false)
138
+ // Initialize from server-provided prop only to avoid hydration flicker
139
+ const [collapsed, setCollapsed] = React.useState(sidebarCollapsedDefault)
140
+ // Maintain internal nav state so we can augment it client-side
141
+ const [navGroups, setNavGroups] = React.useState(AppShell.cloneGroups(groups))
142
+ const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(() =>
143
+ Object.fromEntries(groups.map((g) => [resolveGroupKey(g), true])) as Record<string, boolean>
144
+ )
145
+ const [customizing, setCustomizing] = React.useState(false)
146
+ const [customDraft, setCustomDraft] = React.useState<SidebarCustomizationDraft | null>(null)
147
+ const [loadingPreferences, setLoadingPreferences] = React.useState(false)
148
+ const [savingPreferences, setSavingPreferences] = React.useState(false)
149
+ const [customizationError, setCustomizationError] = React.useState<string | null>(null)
150
+ const [availableRoleTargets, setAvailableRoleTargets] = React.useState<SidebarRoleTarget[]>([])
151
+ const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
152
+ const [canApplyToRoles, setCanApplyToRoles] = React.useState(false)
153
+ const originalNavRef = React.useRef<SidebarGroup[] | null>(null)
154
+ const [headerTitle, setHeaderTitle] = React.useState<string | undefined>(currentTitle)
155
+ const [headerBreadcrumb, setHeaderBreadcrumb] = React.useState<Breadcrumb | undefined>(breadcrumb)
156
+ const effectiveCollapsed = customizing ? false : collapsed
157
+ const expandedSidebarWidth = customizing ? '320px' : '240px'
158
+
159
+ React.useEffect(() => {
160
+ try {
161
+ const savedOpen = typeof window !== 'undefined' ? localStorage.getItem('om:sidebarOpenGroups') : null
162
+ if (!savedOpen) return
163
+ const parsed = JSON.parse(savedOpen) as Record<string, boolean>
164
+ setOpenGroups((prev) => {
165
+ const next = { ...prev }
166
+ for (const group of groups) {
167
+ const key = resolveGroupKey(group)
168
+ if (key in parsed) next[key] = !!parsed[key]
169
+ else if (group.name in parsed) next[key] = !!parsed[group.name]
170
+ }
171
+ return next
172
+ })
173
+ } catch {
174
+ // ignore localStorage errors to avoid breaking hydration
175
+ }
176
+ }, [groups])
177
+
178
+ const toggleGroup = (groupId: string) => setOpenGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }))
179
+
180
+ const updateDraft = React.useCallback((updater: (draft: SidebarCustomizationDraft) => SidebarCustomizationDraft) => {
181
+ setCustomDraft((prev) => {
182
+ if (!prev) return prev
183
+ const next = updater(prev)
184
+ if (originalNavRef.current) {
185
+ setNavGroups(applyCustomizationDraft(originalNavRef.current, next))
186
+ }
187
+ return next
188
+ })
189
+ }, [])
190
+
191
+ const startCustomization = React.useCallback(async () => {
192
+ if (customizing || loadingPreferences) return
193
+ setCustomizationError(null)
194
+ setLoadingPreferences(true)
195
+ try {
196
+ const baseSnapshot = AppShell.cloneGroups(navGroups)
197
+ const call = await apiCall<{
198
+ settings?: Record<string, unknown>
199
+ canApplyToRoles?: boolean
200
+ roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
201
+ }>('/api/auth/sidebar/preferences')
202
+ const data = call.ok ? (call.result ?? null) : null
203
+ const rawSettings = data?.settings
204
+ const responseOrder = Array.isArray(rawSettings?.groupOrder)
205
+ ? rawSettings.groupOrder
206
+ .map((id: unknown) => (typeof id === 'string' ? id.trim() : ''))
207
+ .filter((id: string) => id.length > 0)
208
+ : []
209
+ const responseGroupLabels: Record<string, string> = {}
210
+ if (rawSettings?.groupLabels && typeof rawSettings.groupLabels === 'object') {
211
+ for (const [key, value] of Object.entries(rawSettings.groupLabels as Record<string, unknown>)) {
212
+ if (typeof value !== 'string') continue
213
+ const trimmedKey = key.trim()
214
+ if (!trimmedKey) continue
215
+ responseGroupLabels[trimmedKey] = value
216
+ }
217
+ }
218
+ const responseItemLabels: Record<string, string> = {}
219
+ if (rawSettings?.itemLabels && typeof rawSettings.itemLabels === 'object') {
220
+ for (const [key, value] of Object.entries(rawSettings.itemLabels as Record<string, unknown>)) {
221
+ if (typeof value !== 'string') continue
222
+ const trimmedKey = key.trim()
223
+ if (!trimmedKey) continue
224
+ responseItemLabels[trimmedKey] = value
225
+ }
226
+ }
227
+ const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems)
228
+ ? rawSettings.hiddenItems
229
+ .map((href: unknown) => (typeof href === 'string' ? href.trim() : ''))
230
+ .filter((href: string) => href.length > 0)
231
+ : []
232
+ const canManageRoles = data?.canApplyToRoles === true
233
+ setCanApplyToRoles(canManageRoles)
234
+ if (canManageRoles) {
235
+ const roles = Array.isArray(data?.roles)
236
+ ? (data.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>).filter((role) => typeof role?.id === 'string' && typeof role?.name === 'string')
237
+ : []
238
+ const mappedRoles: SidebarRoleTarget[] = roles.map((role) => ({
239
+ id: role.id as string,
240
+ name: role.name as string,
241
+ hasPreference: role.hasPreference === true,
242
+ }))
243
+ setAvailableRoleTargets(mappedRoles)
244
+ setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
245
+ } else {
246
+ setAvailableRoleTargets([])
247
+ setSelectedRoleIds([])
248
+ }
249
+ const currentIds = baseSnapshot.map((group) => resolveGroupKey(group))
250
+ const order = mergeGroupOrder(responseOrder, currentIds)
251
+ const { itemDefaults } = collectSidebarDefaults(baseSnapshot)
252
+ const hiddenItemIds: Record<string, boolean> = {}
253
+ for (const href of responseHiddenItems) {
254
+ if (!itemDefaults.has(href)) continue
255
+ hiddenItemIds[href] = true
256
+ }
257
+ const draft: SidebarCustomizationDraft = {
258
+ order,
259
+ groupLabels: { ...responseGroupLabels },
260
+ itemLabels: { ...responseItemLabels },
261
+ hiddenItemIds,
262
+ }
263
+ originalNavRef.current = baseSnapshot
264
+ setCustomDraft(draft)
265
+ setNavGroups(applyCustomizationDraft(baseSnapshot, draft))
266
+ setCustomizing(true)
267
+ } catch (error) {
268
+ console.error('Failed to load sidebar preferences', error)
269
+ setCustomizationError(t('appShell.sidebarCustomizationLoadError'))
270
+ } finally {
271
+ setLoadingPreferences(false)
272
+ }
273
+ }, [customizing, loadingPreferences, navGroups, t])
274
+
275
+ const cancelCustomization = React.useCallback(() => {
276
+ setCustomizing(false)
277
+ setCustomDraft(null)
278
+ setCustomizationError(null)
279
+ setAvailableRoleTargets([])
280
+ setSelectedRoleIds([])
281
+ setCanApplyToRoles(false)
282
+ if (originalNavRef.current) {
283
+ setNavGroups(AppShell.cloneGroups(originalNavRef.current))
284
+ }
285
+ originalNavRef.current = null
286
+ }, [])
287
+
288
+ const resetCustomization = React.useCallback(() => {
289
+ if (!originalNavRef.current) return
290
+ const base = AppShell.cloneGroups(originalNavRef.current)
291
+ const order = base.map((group) => resolveGroupKey(group))
292
+ const draft: SidebarCustomizationDraft = { order, groupLabels: {}, itemLabels: {}, hiddenItemIds: {} }
293
+ originalNavRef.current = base
294
+ setCustomDraft(draft)
295
+ setNavGroups(applyCustomizationDraft(base, draft))
296
+ if (canApplyToRoles) {
297
+ setSelectedRoleIds(availableRoleTargets.filter((role) => role.hasPreference).map((role) => role.id))
298
+ }
299
+ }, [availableRoleTargets, canApplyToRoles])
300
+
301
+ const saveCustomization = React.useCallback(async () => {
302
+ if (!customDraft) return
303
+ setSavingPreferences(true)
304
+ setCustomizationError(null)
305
+ try {
306
+ const baseGroups = originalNavRef.current ?? AppShell.cloneGroups(navGroups)
307
+ const { groupDefaults, itemDefaults } = collectSidebarDefaults(baseGroups)
308
+ const sanitizedGroupLabels: Record<string, string> = {}
309
+ for (const [key, value] of Object.entries(customDraft.groupLabels)) {
310
+ const trimmed = value.trim()
311
+ const base = groupDefaults.get(key)
312
+ if (!trimmed || !base) continue
313
+ if (trimmed !== base) sanitizedGroupLabels[key] = trimmed
314
+ }
315
+ const sanitizedItemLabels: Record<string, string> = {}
316
+ for (const [href, value] of Object.entries(customDraft.itemLabels)) {
317
+ const trimmed = value.trim()
318
+ const base = itemDefaults.get(href)
319
+ if (!trimmed || !base) continue
320
+ if (trimmed !== base) sanitizedItemLabels[href] = trimmed
321
+ }
322
+ const sanitizedHiddenItems: string[] = []
323
+ for (const [href, hidden] of Object.entries(customDraft.hiddenItemIds)) {
324
+ if (!hidden) continue
325
+ if (!itemDefaults.has(href)) continue
326
+ sanitizedHiddenItems.push(href)
327
+ }
328
+ const applyToRolesPayload = canApplyToRoles ? [...selectedRoleIds] : []
329
+ const clearRoleIdsPayload = canApplyToRoles
330
+ ? availableRoleTargets
331
+ .filter((role) => role.hasPreference && !selectedRoleIds.includes(role.id))
332
+ .map((role) => role.id)
333
+ : []
334
+ const payload: Record<string, unknown> = {
335
+ groupOrder: customDraft.order,
336
+ groupLabels: sanitizedGroupLabels,
337
+ itemLabels: sanitizedItemLabels,
338
+ hiddenItems: sanitizedHiddenItems,
339
+ }
340
+ if (canApplyToRoles) {
341
+ payload.applyToRoles = applyToRolesPayload
342
+ payload.clearRoleIds = clearRoleIdsPayload
343
+ }
344
+ const call = await apiCall<{
345
+ canApplyToRoles?: boolean
346
+ roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
347
+ }>('/api/auth/sidebar/preferences', {
348
+ method: 'PUT',
349
+ headers: { 'content-type': 'application/json' },
350
+ body: JSON.stringify(payload),
351
+ })
352
+ if (!call.ok) {
353
+ setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
354
+ return
355
+ }
356
+ const data = call.result ?? null
357
+ if (data?.canApplyToRoles !== undefined) {
358
+ setCanApplyToRoles(data.canApplyToRoles === true)
359
+ }
360
+ if (Array.isArray(data?.roles)) {
361
+ const mappedRoles: SidebarRoleTarget[] = (data.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>).filter((role) => typeof role?.id === 'string' && typeof role?.name === 'string').map((role) => ({
362
+ id: role.id as string,
363
+ name: role.name as string,
364
+ hasPreference: role.hasPreference === true,
365
+ }))
366
+ setAvailableRoleTargets(mappedRoles)
367
+ setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
368
+ }
369
+ originalNavRef.current = applyCustomizationDraft(baseGroups, customDraft)
370
+ setNavGroups(AppShell.cloneGroups(originalNavRef.current))
371
+ setCustomizing(false)
372
+ setCustomDraft(null)
373
+ try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
374
+ } catch (error) {
375
+ console.error('Failed to save sidebar preferences', error)
376
+ setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
377
+ } finally {
378
+ setSavingPreferences(false)
379
+ }
380
+ }, [customDraft, navGroups, t])
381
+
382
+ const moveGroup = React.useCallback((groupId: string, offset: number) => {
383
+ updateDraft((draft) => {
384
+ const order = [...draft.order]
385
+ const index = order.indexOf(groupId)
386
+ if (index === -1) return draft
387
+ const nextIndex = Math.max(0, Math.min(order.length - 1, index + offset))
388
+ if (nextIndex === index) return draft
389
+ order.splice(index, 1)
390
+ order.splice(nextIndex, 0, groupId)
391
+ return { ...draft, order }
392
+ })
393
+ }, [updateDraft])
394
+
395
+ const setGroupLabel = React.useCallback((groupId: string, value: string) => {
396
+ updateDraft((draft) => {
397
+ const next = { ...draft.groupLabels }
398
+ if (value.trim().length === 0) delete next[groupId]
399
+ else next[groupId] = value
400
+ return { ...draft, groupLabels: next }
401
+ })
402
+ }, [updateDraft])
403
+
404
+ const setItemLabel = React.useCallback((href: string, value: string) => {
405
+ updateDraft((draft) => {
406
+ const next = { ...draft.itemLabels }
407
+ if (value.trim().length === 0) delete next[href]
408
+ else next[href] = value
409
+ return { ...draft, itemLabels: next }
410
+ })
411
+ }, [updateDraft])
412
+ const setItemHidden = React.useCallback((href: string, hidden: boolean) => {
413
+ updateDraft((draft) => {
414
+ const next = { ...draft.hiddenItemIds }
415
+ if (hidden) next[href] = true
416
+ else delete next[href]
417
+ return { ...draft, hiddenItemIds: next }
418
+ })
419
+ }, [updateDraft])
420
+
421
+ const toggleRoleSelection = React.useCallback((roleId: string) => {
422
+ setSelectedRoleIds((prev) => (prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]))
423
+ }, [])
424
+
425
+ const asideWidth = effectiveCollapsed ? '72px' : expandedSidebarWidth
426
+ // Use min-h-svh so the border extends with tall content; keep overflow for long menus
427
+ const asideClassesBase = `border-r bg-background/60 py-4 min-h-svh overflow-y-auto`;
428
+
429
+ // Persist collapse state to localStorage and cookie
430
+ React.useEffect(() => {
431
+ try { localStorage.setItem('om:sidebarCollapsed', collapsed ? '1' : '0') } catch {}
432
+ try {
433
+ document.cookie = `om_sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
434
+ } catch {}
435
+ }, [collapsed])
436
+ React.useEffect(() => {
437
+ try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch {}
438
+ }, [openGroups])
439
+
440
+ // Ensure current route's group is expanded on load
441
+ React.useEffect(() => {
442
+ const activeGroup = navGroups.find((g) => g.items.some((i) => pathname?.startsWith(i.href)))
443
+ if (!activeGroup) return
444
+ const key = resolveGroupKey(activeGroup)
445
+ setOpenGroups((prev) => (prev[key] === false ? { ...prev, [key]: true } : prev))
446
+ // eslint-disable-next-line react-hooks/exhaustive-deps
447
+ }, [pathname, navGroups])
448
+ // Keep header state in sync with props (server-side updates)
449
+ React.useEffect(() => {
450
+ setHeaderTitle(currentTitle)
451
+ setHeaderBreadcrumb(breadcrumb)
452
+ }, [currentTitle, breadcrumb])
453
+
454
+ // Keep navGroups in sync when server-provided groups change
455
+ React.useEffect(() => {
456
+ if (customizing && customDraft && originalNavRef.current) {
457
+ originalNavRef.current = AppShell.cloneGroups(groups)
458
+ setNavGroups(applyCustomizationDraft(originalNavRef.current, customDraft))
459
+ return
460
+ }
461
+ setNavGroups(AppShell.cloneGroups(groups))
462
+ }, [groups, customizing, customDraft])
463
+
464
+ // Optional: full refresh from adminNavApi, used to reflect RBAC/org/entity changes without page reload
465
+ React.useEffect(() => {
466
+ let cancelled = false
467
+ function indexIcons(groupsToIndex: AppShellProps['groups']): Map<string, React.ReactNode | undefined> {
468
+ const map = new Map<string, React.ReactNode | undefined>()
469
+ for (const g of groupsToIndex) {
470
+ for (const i of g.items) {
471
+ map.set(i.href, i.icon)
472
+ if (i.children) for (const c of i.children) map.set(c.href, c.icon)
473
+ }
474
+ }
475
+ return map
476
+ }
477
+ function mergePreservingIcons(oldG: AppShellProps['groups'], newG: AppShellProps['groups']): AppShellProps['groups'] {
478
+ const iconMap = indexIcons(oldG)
479
+ const merged = newG.map((g) => ({
480
+ id: g.id,
481
+ name: g.name,
482
+ defaultName: g.defaultName,
483
+ items: g.items.map((i) => ({
484
+ href: i.href,
485
+ title: i.title,
486
+ defaultTitle: i.defaultTitle,
487
+ enabled: i.enabled,
488
+ hidden: i.hidden,
489
+ icon: i.icon ?? iconMap.get(i.href),
490
+ children: i.children?.map((c) => ({
491
+ href: c.href,
492
+ title: c.title,
493
+ defaultTitle: c.defaultTitle,
494
+ enabled: c.enabled,
495
+ hidden: c.hidden,
496
+ icon: c.icon ?? iconMap.get(c.href),
497
+ })),
498
+ })),
499
+ }))
500
+ return merged
501
+ }
502
+ async function refreshFullNav() {
503
+ if (!adminNavApi) return
504
+ try {
505
+ const call = await apiCall<{ groups?: unknown[] }>(adminNavApi, { credentials: 'include' as any })
506
+ if (!call.ok) return
507
+ const data = call.result
508
+ if (cancelled) return
509
+ const nextGroups = Array.isArray(data?.groups) ? data.groups : []
510
+ if (nextGroups.length) setNavGroups((prev) => AppShell.cloneGroups(mergePreservingIcons(prev, nextGroups as any)))
511
+ } catch {}
512
+ }
513
+ // Refresh on window focus
514
+ const onFocus = () => refreshFullNav()
515
+ window.addEventListener('focus', onFocus)
516
+ return () => { cancelled = true; window.removeEventListener('focus', onFocus) }
517
+ }, [adminNavApi])
518
+
519
+ // Refresh sidebar when other parts of the app dispatch an explicit event
520
+ React.useEffect(() => {
521
+ if (!adminNavApi) return
522
+ const api = adminNavApi as string
523
+ let cancelled = false
524
+ function indexIcons(groupsToIndex: AppShellProps['groups']): Map<string, React.ReactNode | undefined> {
525
+ const map = new Map<string, React.ReactNode | undefined>()
526
+ for (const g of groupsToIndex) {
527
+ for (const i of g.items) {
528
+ map.set(i.href, i.icon)
529
+ if (i.children) for (const c of i.children) map.set(c.href, c.icon)
530
+ }
531
+ }
532
+ return map
533
+ }
534
+ function mergePreservingIcons(oldG: AppShellProps['groups'], newG: AppShellProps['groups']): AppShellProps['groups'] {
535
+ const iconMap = indexIcons(oldG)
536
+ const merged = newG.map((g) => ({
537
+ name: g.name,
538
+ items: g.items.map((i) => ({
539
+ href: i.href,
540
+ title: i.title,
541
+ enabled: i.enabled,
542
+ hidden: i.hidden,
543
+ icon: i.icon ?? iconMap.get(i.href),
544
+ children: i.children?.map((c) => ({
545
+ href: c.href,
546
+ title: c.title,
547
+ enabled: c.enabled,
548
+ hidden: c.hidden,
549
+ icon: c.icon ?? iconMap.get(c.href),
550
+ })),
551
+ })),
552
+ }))
553
+ return merged
554
+ }
555
+ async function refreshFullNav() {
556
+ try {
557
+ const call = await apiCall<{ groups?: unknown[] }>(api, { credentials: 'include' as any })
558
+ if (!call.ok) return
559
+ const data = call.result
560
+ if (cancelled) return
561
+ const nextGroups = Array.isArray(data?.groups) ? data.groups : []
562
+ if (nextGroups.length) setNavGroups((prev) => AppShell.cloneGroups(mergePreservingIcons(prev, nextGroups as any)))
563
+ } catch {}
564
+ }
565
+ const onRefresh = () => { refreshFullNav() }
566
+ window.addEventListener('om:refresh-sidebar', onRefresh as any)
567
+ return () => { cancelled = true; window.removeEventListener('om:refresh-sidebar', onRefresh as any) }
568
+ }, [adminNavApi])
569
+
570
+ // adminNavApi already includes user entities; no extra fetch
571
+
572
+ function renderSidebar(compact: boolean, hideHeader?: boolean) {
573
+ const isMobileVariant = !!hideHeader
574
+ const baseGroupsForDefaults = originalNavRef.current ?? navGroups
575
+ const baseGroupMap = new Map<string, SidebarGroup>()
576
+ for (const group of baseGroupsForDefaults) {
577
+ baseGroupMap.set(resolveGroupKey(group), group)
578
+ }
579
+ const localeLabel = (locale || '').toUpperCase()
580
+
581
+ const orderedGroupIds = customDraft
582
+ ? mergeGroupOrder(customDraft.order, Array.from(baseGroupMap.keys()))
583
+ : navGroups.map((group) => resolveGroupKey(group))
584
+
585
+ const lastVisibleGroupIndex = (() => {
586
+ for (let idx = navGroups.length - 1; idx >= 0; idx -= 1) {
587
+ if (navGroups[idx].items.some((item) => item.hidden !== true)) return idx
588
+ }
589
+ return -1
590
+ })()
591
+
592
+ const renderEditableItems = (baseItems: SidebarItem[], currentItems: SidebarItem[], depth = 0): React.ReactNode => {
593
+ if (!customDraft) return null
594
+ return baseItems.map((baseItem) => {
595
+ const current = currentItems.find((item) => item.href === baseItem.href) ?? baseItem
596
+ const placeholder = baseItem.defaultTitle ?? baseItem.title
597
+ const value = customDraft.itemLabels[baseItem.href] ?? ''
598
+ const hidden = customDraft.hiddenItemIds[baseItem.href] === true
599
+ return (
600
+ <div
601
+ key={baseItem.href}
602
+ className={`flex flex-col gap-1 ${hidden ? 'opacity-60' : ''}`}
603
+ style={depth ? { marginLeft: depth * 16 } : undefined}
604
+ >
605
+ <span className="text-xs font-medium text-muted-foreground">{placeholder}</span>
606
+ <div className="flex items-center gap-2">
607
+ <input
608
+ type="checkbox"
609
+ className="h-4 w-4 accent-foreground"
610
+ checked={!hidden}
611
+ onChange={(event) => setItemHidden(baseItem.href, !event.target.checked)}
612
+ disabled={savingPreferences}
613
+ aria-label={t('appShell.sidebarCustomizationShowItem')}
614
+ title={t('appShell.sidebarCustomizationShowItem')}
615
+ />
616
+ <input
617
+ value={value}
618
+ onChange={(event) => setItemLabel(baseItem.href, event.target.value)}
619
+ placeholder={placeholder}
620
+ disabled={savingPreferences}
621
+ className="h-8 flex-1 rounded border bg-background px-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
622
+ />
623
+ </div>
624
+ {baseItem.children && baseItem.children.length > 0 ? (
625
+ <div className="flex flex-col gap-1">
626
+ {renderEditableItems(baseItem.children, current.children ?? [], depth + 1)}
627
+ </div>
628
+ ) : null}
629
+ </div>
630
+ )
631
+ })
632
+ }
633
+
634
+ const customizationEditor = customizing ? (
635
+ customDraft ? (
636
+ <div className="flex flex-col gap-3 rounded border border-dashed bg-muted/20 p-3">
637
+ <div className="flex flex-wrap items-center justify-between gap-2">
638
+ <div className="text-sm font-semibold">{t('appShell.sidebarCustomizationHeading')}</div>
639
+ <div className="flex items-center gap-2">
640
+ <button
641
+ type="button"
642
+ className="h-8 rounded border px-3 text-sm"
643
+ onClick={resetCustomization}
644
+ disabled={savingPreferences}
645
+ >
646
+ {t('appShell.sidebarCustomizationReset')}
647
+ </button>
648
+ <button
649
+ type="button"
650
+ className="h-8 rounded border px-3 text-sm"
651
+ onClick={cancelCustomization}
652
+ disabled={savingPreferences}
653
+ >
654
+ {t('appShell.sidebarCustomizationCancel')}
655
+ </button>
656
+ <button
657
+ type="button"
658
+ className="h-8 rounded bg-foreground px-3 text-sm font-medium text-background disabled:opacity-60"
659
+ onClick={saveCustomization}
660
+ disabled={savingPreferences}
661
+ >
662
+ {savingPreferences ? t('appShell.sidebarCustomizationSaving') : t('appShell.sidebarCustomizationSave')}
663
+ </button>
664
+ </div>
665
+ </div>
666
+ <p className="text-xs text-muted-foreground">{t('appShell.sidebarCustomizationHint', { locale: localeLabel })}</p>
667
+ {canApplyToRoles ? (
668
+ <div className="flex flex-col gap-2 rounded border bg-background/70 p-3 shadow-sm">
669
+ <div>
670
+ <div className="text-sm font-semibold">{t('appShell.sidebarApplyToRolesTitle')}</div>
671
+ <p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesDescription')}</p>
672
+ </div>
673
+ {availableRoleTargets.length > 0 ? (
674
+ <div className="flex flex-col gap-2">
675
+ {availableRoleTargets.map((role) => {
676
+ const checked = selectedRoleIds.includes(role.id)
677
+ const willClear = role.hasPreference && !checked
678
+ return (
679
+ <label key={role.id} className="flex items-center gap-2 rounded border bg-background px-2 py-1 text-sm shadow-sm">
680
+ <input
681
+ type="checkbox"
682
+ className="h-4 w-4 accent-foreground"
683
+ checked={checked}
684
+ onChange={() => toggleRoleSelection(role.id)}
685
+ disabled={savingPreferences}
686
+ />
687
+ <span className="flex-1 truncate">{role.name}</span>
688
+ {role.hasPreference ? (
689
+ <span className={`text-xs ${willClear ? 'text-destructive' : 'text-muted-foreground'}`}>
690
+ {willClear ? t('appShell.sidebarRoleWillClear') : t('appShell.sidebarRoleHasPreset')}
691
+ </span>
692
+ ) : null}
693
+ </label>
694
+ )
695
+ })}
696
+ </div>
697
+ ) : (
698
+ <p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesEmpty')}</p>
699
+ )}
700
+ </div>
701
+ ) : null}
702
+ {customizationError ? <p className="text-xs text-destructive">{customizationError}</p> : null}
703
+ <div className="flex flex-col gap-3">
704
+ {orderedGroupIds.map((groupId, index) => {
705
+ const baseGroup = baseGroupMap.get(groupId)
706
+ if (!baseGroup) return null
707
+ const currentGroup = navGroups.find((group) => resolveGroupKey(group) === groupId) ?? baseGroup
708
+ const placeholder = baseGroup.defaultName ?? baseGroup.name
709
+ const value = customDraft.groupLabels[groupId] ?? ''
710
+ return (
711
+ <div key={groupId} className="flex flex-col gap-3 rounded border bg-background p-3 shadow-sm">
712
+ <div className={`flex ${compact ? 'flex-col gap-2' : 'items-center gap-2'}`}>
713
+ <div className="flex-1">
714
+ <span className="text-xs font-medium text-muted-foreground">{t('appShell.sidebarCustomizationGroupLabel')}</span>
715
+ <input
716
+ value={value}
717
+ onChange={(event) => setGroupLabel(groupId, event.target.value)}
718
+ placeholder={placeholder}
719
+ disabled={savingPreferences}
720
+ className="mt-1 h-8 w-full rounded border bg-background px-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
721
+ />
722
+ </div>
723
+ <div className="flex items-center gap-1 self-start">
724
+ <button
725
+ type="button"
726
+ className="h-8 w-8 rounded border text-muted-foreground hover:text-foreground disabled:opacity-40"
727
+ onClick={() => moveGroup(groupId, -1)}
728
+ disabled={index === 0 || savingPreferences}
729
+ aria-label={t('appShell.sidebarCustomizationMoveUp')}
730
+ >
731
+
732
+ </button>
733
+ <button
734
+ type="button"
735
+ className="h-8 w-8 rounded border text-muted-foreground hover:text-foreground disabled:opacity-40"
736
+ onClick={() => moveGroup(groupId, 1)}
737
+ disabled={index === orderedGroupIds.length - 1 || savingPreferences}
738
+ aria-label={t('appShell.sidebarCustomizationMoveDown')}
739
+ >
740
+
741
+ </button>
742
+ </div>
743
+ </div>
744
+ <div className="flex flex-col gap-2">
745
+ {renderEditableItems(baseGroup.items, currentGroup.items)}
746
+ </div>
747
+ </div>
748
+ )
749
+ })}
750
+ </div>
751
+ </div>
752
+ ) : (
753
+ <div className="rounded border border-dashed bg-muted/20 p-3 text-sm text-muted-foreground">
754
+ {t('appShell.sidebarCustomizationLoading')}
755
+ </div>
756
+ )
757
+ ) : null
758
+
759
+ return (
760
+ <div className="flex flex-col min-h-full gap-3">
761
+ {!hideHeader && (
762
+ <div className={`flex items-center ${compact ? 'justify-center' : 'justify-between'} mb-2`}>
763
+ <Link href="/backend" className="flex items-center gap-2" aria-label={t('appShell.goToDashboard')}>
764
+ <Image src="/open-mercato.svg" alt={resolvedProductName} width={32} height={32} className="rounded m-4" />
765
+ {!compact && <div className="text-m font-semibold">{resolvedProductName}</div>}
766
+ </Link>
767
+ </div>
768
+ )}
769
+ <div className="flex flex-1 flex-col gap-3 overflow-y-auto pr-1">
770
+ {customizing ? (
771
+ customizationEditor
772
+ ) : (
773
+ <nav className="flex flex-col gap-2">
774
+ {navGroups.map((g, gi) => {
775
+ const groupId = resolveGroupKey(g)
776
+ const open = openGroups[groupId] !== false
777
+ const visibleItems = g.items.filter((item) => item.hidden !== true)
778
+ if (visibleItems.length === 0) return null
779
+ return (
780
+ <div key={groupId}>
781
+ <button
782
+ type="button"
783
+ onClick={() => toggleGroup(groupId)}
784
+ className={`w-full ${compact ? 'px-0 justify-center' : 'px-2 justify-between'} flex items-center text-xs uppercase text-muted-foreground/90 py-2`}
785
+ aria-expanded={open}
786
+ >
787
+ {!compact && <span>{g.name}</span>}
788
+ {!compact && <Chevron open={open} />}
789
+ </button>
790
+ {open && (
791
+ <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-1' : ''}`}>
792
+ {visibleItems.map((i) => {
793
+ const childItems = (i.children ?? []).filter((child) => child.hidden !== true)
794
+ const showChildren = !!pathname && childItems.length > 0 && pathname.startsWith(i.href)
795
+ const hasActiveChild = !!(pathname && childItems.some((c) => pathname.startsWith(c.href)))
796
+ const isParentActive = (pathname === i.href) || (showChildren && !hasActiveChild)
797
+ const base = compact ? 'w-10 h-10 justify-center' : 'px-2 py-1 gap-2'
798
+ return (
799
+ <React.Fragment key={i.href}>
800
+ <Link
801
+ href={i.href}
802
+ className={`relative text-sm rounded inline-flex items-center ${base} ${
803
+ isParentActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
804
+ } ${i.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
805
+ aria-disabled={i.enabled === false}
806
+ title={compact ? i.title : undefined}
807
+ onClick={() => setMobileOpen(false)}
808
+ >
809
+ {isParentActive ? (
810
+ <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
811
+ ) : null}
812
+ <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
813
+ {i.icon ?? DefaultIcon}
814
+ </span>
815
+ {!compact && <span>{i.title}</span>}
816
+ </Link>
817
+ {showChildren ? (
818
+ <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-4' : ''}`}>
819
+ {childItems.map((c) => {
820
+ const childActive = pathname?.startsWith(c.href)
821
+ const childBase = compact ? 'w-10 h-8 justify-center' : 'px-2 py-1 gap-2'
822
+ return (
823
+ <Link
824
+ key={c.href}
825
+ href={c.href}
826
+ className={`relative text-sm rounded inline-flex items-center ${childBase} ${
827
+ childActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
828
+ } ${c.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
829
+ aria-disabled={c.enabled === false}
830
+ title={compact ? c.title : undefined}
831
+ onClick={() => setMobileOpen(false)}
832
+ >
833
+ {childActive ? (
834
+ <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
835
+ ) : null}
836
+ <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
837
+ {c.icon ?? (c.href.includes('/backend/entities/user/') && c.href.endsWith('/records') ? DataTableIcon : DefaultIcon)}
838
+ </span>
839
+ {!compact && <span>{c.title}</span>}
840
+ </Link>
841
+ )
842
+ })}
843
+ </div>
844
+ ) : null}
845
+ </React.Fragment>
846
+ )
847
+ })}
848
+ </div>
849
+ )}
850
+ {gi !== lastVisibleGroupIndex && <div className="my-2 border-t border-dotted" />}
851
+ </div>
852
+ )
853
+ })}
854
+ </nav>
855
+ )}
856
+ </div>
857
+ {!customizing && (
858
+ <button
859
+ type="button"
860
+ onClick={startCustomization}
861
+ className={`mt-auto inline-flex items-center justify-center gap-2 rounded border hover:bg-accent hover:text-accent-foreground disabled:opacity-60 ${
862
+ compact || isMobileVariant ? 'h-10 w-10 p-0' : 'h-9 px-3 text-sm font-medium'
863
+ }`}
864
+ disabled={loadingPreferences}
865
+ aria-label={t('appShell.customizeSidebar')}
866
+ >
867
+ <span className="flex items-center justify-center">{CustomizeIcon}</span>
868
+ {!(compact || isMobileVariant) && (
869
+ <span>{loadingPreferences ? t('appShell.sidebarCustomizationLoading') : t('appShell.customizeSidebar')}</span>
870
+ )}
871
+ </button>
872
+ )}
873
+ </div>
874
+ )
875
+ }
876
+
877
+ const gridColsClass = customizing
878
+ ? 'lg:grid-cols-[320px_1fr]'
879
+ : (effectiveCollapsed ? 'lg:grid-cols-[72px_1fr]' : 'lg:grid-cols-[240px_1fr]')
880
+ const headerCtxValue = React.useMemo(() => ({
881
+ setBreadcrumb: setHeaderBreadcrumb,
882
+ setTitle: setHeaderTitle,
883
+ }), [])
884
+
885
+ return (
886
+ <HeaderContext.Provider value={headerCtxValue}>
887
+ <div className={`min-h-svh lg:grid ${gridColsClass}`}>
888
+ {/* Desktop sidebar */}
889
+ <aside className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block`} style={{ width: asideWidth }}>{renderSidebar(effectiveCollapsed)}</aside>
890
+
891
+ <div className="flex min-h-svh flex-col min-w-0">
892
+ <header className="border-b bg-background/60 px-3 lg:px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
893
+ <div className="flex items-center gap-2 flex-wrap">
894
+ {/* Mobile menu button */}
895
+ <button type="button" className="lg:hidden rounded border px-2 py-1" aria-label={t('appShell.openMenu')} onClick={() => setMobileOpen(true)}>
896
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
897
+ </button>
898
+ {/* Desktop collapse toggle */}
899
+ <button
900
+ type="button"
901
+ className="hidden lg:inline-flex rounded border px-2 py-1 disabled:opacity-60"
902
+ aria-label={t('appShell.toggleSidebar')}
903
+ onClick={() => setCollapsed((c) => !c)}
904
+ disabled={customizing}
905
+ >
906
+ {/* Sidebar toggle icon */}
907
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
908
+ <rect x="3" y="4" width="18" height="16" rx="2"/>
909
+ <path d="M9 4v16"/>
910
+ </svg>
911
+ </button>
912
+ {/* Header breadcrumb: always starts with Dashboard */}
913
+ {(() => {
914
+ const dashboardLabel = t('dashboard.title')
915
+ const root: Breadcrumb = [{ label: dashboardLabel, href: '/backend' }]
916
+ let rest: Breadcrumb = []
917
+ if (headerBreadcrumb && headerBreadcrumb.length) {
918
+ const first = headerBreadcrumb[0]
919
+ const dup = first && (first.href === '/backend' || first.label === dashboardLabel || first.label?.toLowerCase() === 'dashboard')
920
+ rest = dup ? headerBreadcrumb.slice(1) : headerBreadcrumb
921
+ } else if (headerTitle) {
922
+ rest = [{ label: headerTitle }]
923
+ }
924
+ const items = [...root, ...rest]
925
+ return (
926
+ <nav className="flex items-center gap-2 text-sm">
927
+ {items.map((b, i) => (
928
+ <React.Fragment key={i}>
929
+ {i > 0 && <span className="text-muted-foreground">/</span>}
930
+ {b.href ? (
931
+ <Link href={b.href} className="text-muted-foreground hover:text-foreground">
932
+ {b.label}
933
+ </Link>
934
+ ) : (
935
+ <span className="font-medium truncate max-w-[60vw]">{b.label}</span>
936
+ )}
937
+ </React.Fragment>
938
+ ))}
939
+ </nav>
940
+ )
941
+ })()}
942
+ </div>
943
+ <div className="flex items-center gap-2 text-sm w-full lg:w-auto lg:justify-end">
944
+ <ThemeToggle />
945
+ <Separator className="w-px h-5 mx-1" />
946
+ {rightHeaderSlot ? (
947
+ rightHeaderSlot
948
+ ) : (
949
+ <span className="opacity-80">{email || t('appShell.userFallback')}</span>
950
+ )}
951
+ </div>
952
+ </header>
953
+ <main className="flex-1 p-4 lg:p-6">
954
+ <FlashMessages />
955
+ <PartialIndexBanner />
956
+ <UpgradeActionBanner />
957
+ <LastOperationBanner />
958
+ {children}
959
+ </main>
960
+ <footer className="border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/50 px-4 py-3 flex flex-wrap items-center justify-end gap-4">
961
+ {version ? (
962
+ <span className="text-xs text-muted-foreground">
963
+ {t('appShell.version', { version })}
964
+ </span>
965
+ ) : null}
966
+ <nav className="flex items-center gap-3 text-xs text-muted-foreground">
967
+ <Link href="/terms" className="transition hover:text-foreground">
968
+ {t('common.terms')}
969
+ </Link>
970
+ <Link href="/privacy" className="transition hover:text-foreground">
971
+ {t('common.privacy')}
972
+ </Link>
973
+ </nav>
974
+ <LanguageSwitcher />
975
+ </footer>
976
+ </div>
977
+
978
+ {/* Mobile drawer */}
979
+ {mobileOpen && (
980
+ <div className="lg:hidden fixed inset-0 z-50">
981
+ <div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
982
+ <aside className="absolute left-0 top-0 h-full w-[260px] bg-background border-r p-3">
983
+ <div className="mb-2 flex items-center justify-between">
984
+ <Link href="/backend" className="flex items-center gap-2 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
985
+ <Image src="/open-mercato.svg" alt={resolvedProductName} width={28} height={28} className="rounded" />
986
+ {resolvedProductName}
987
+ </Link>
988
+ <button className="rounded border px-2 py-1" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>✕</button>
989
+ </div>
990
+ {/* Force expanded sidebar in mobile drawer, hide its header and collapse toggle */}
991
+ {renderSidebar(false, true)}
992
+ </aside>
993
+ </div>
994
+ )}
995
+ </div>
996
+ </HeaderContext.Provider>
997
+ )
998
+ }
999
+
1000
+ // Helper: deep-clone minimal shape we mutate (children arrays)
1001
+ AppShell.cloneGroups = function cloneGroups(groups: AppShellProps['groups']): AppShellProps['groups'] {
1002
+ const cloneItem = (item: SidebarItem): SidebarItem => ({
1003
+ href: item.href,
1004
+ title: item.title,
1005
+ defaultTitle: item.defaultTitle,
1006
+ icon: item.icon,
1007
+ enabled: item.enabled,
1008
+ hidden: item.hidden,
1009
+ children: item.children ? item.children.map((child) => cloneItem(child)) : undefined,
1010
+ })
1011
+ return groups.map((group) => ({
1012
+ id: group.id,
1013
+ name: group.name,
1014
+ defaultName: group.defaultName,
1015
+ items: group.items.map((item) => cloneItem(item)),
1016
+ }))
1017
+ }
1018
+
1019
+ function applyCustomizationDraft(baseGroups: SidebarGroup[], draft: SidebarCustomizationDraft): SidebarGroup[] {
1020
+ const clones = AppShell.cloneGroups(baseGroups)
1021
+ const byId = new Map<string, SidebarGroup>()
1022
+ for (const group of clones) {
1023
+ byId.set(resolveGroupKey(group), group)
1024
+ }
1025
+ const orderedIds = mergeGroupOrder(draft.order, Array.from(byId.keys()))
1026
+ const seen = new Set<string>()
1027
+ const result: SidebarGroup[] = []
1028
+ for (const id of orderedIds) {
1029
+ if (seen.has(id)) continue
1030
+ const group = byId.get(id)
1031
+ if (!group) continue
1032
+ seen.add(id)
1033
+ const baseName = group.defaultName ?? group.name
1034
+ const override = draft.groupLabels[id]?.trim()
1035
+ result.push({
1036
+ ...group,
1037
+ name: override && override.length > 0 ? override : baseName,
1038
+ items: group.items.map((item) => applyItemDraft(item, draft)),
1039
+ })
1040
+ }
1041
+ return result
1042
+ }
1043
+
1044
+ function applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {
1045
+ const baseTitle = item.defaultTitle ?? item.title
1046
+ const override = draft.itemLabels[item.href]?.trim()
1047
+ const children = item.children
1048
+ ? item.children
1049
+ .map((child) => applyItemDraft(child, draft))
1050
+ : undefined
1051
+ const hidden = draft.hiddenItemIds[item.href] === true
1052
+ return {
1053
+ ...item,
1054
+ title: override && override.length > 0 ? override : baseTitle,
1055
+ hidden,
1056
+ children,
1057
+ }
1058
+ }
1059
+
1060
+ function mergeGroupOrder(preferred: string[], current: string[]): string[] {
1061
+ const seen = new Set<string>()
1062
+ const merged: string[] = []
1063
+ for (const id of preferred) {
1064
+ const trimmed = id.trim()
1065
+ if (!trimmed || seen.has(trimmed) || !current.includes(trimmed)) continue
1066
+ seen.add(trimmed)
1067
+ merged.push(trimmed)
1068
+ }
1069
+ for (const id of current) {
1070
+ if (seen.has(id)) continue
1071
+ seen.add(id)
1072
+ merged.push(id)
1073
+ }
1074
+ return merged
1075
+ }
1076
+
1077
+ function collectSidebarDefaults(groups: SidebarGroup[]) {
1078
+ const groupDefaults = new Map<string, string>()
1079
+ const itemDefaults = new Map<string, string>()
1080
+
1081
+ const visitItems = (items: SidebarItem[]) => {
1082
+ for (const item of items) {
1083
+ const baseTitle = item.defaultTitle ?? item.title
1084
+ itemDefaults.set(item.href, baseTitle)
1085
+ if (item.children && item.children.length > 0) visitItems(item.children)
1086
+ }
1087
+ }
1088
+
1089
+ for (const group of groups) {
1090
+ const key = resolveGroupKey(group)
1091
+ groupDefaults.set(key, group.defaultName ?? group.name)
1092
+ visitItems(group.items)
1093
+ }
1094
+
1095
+ return { groupDefaults, itemDefaults }
1096
+ }