portal-mojo 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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,107 @@
1
+ import {
2
+ createContext, useCallback, useContext, useEffect, useId, useLayoutEffect,
3
+ useRef, useState, type ReactNode,
4
+ } from 'react';
5
+
6
+ export interface RightPanelRenderContext {
7
+ close(): void;
8
+ }
9
+
10
+ export interface RightPanelDescriptor {
11
+ /** Stable session identity. Opening the same key is idempotent. */
12
+ key: string;
13
+ /** Visible accessible heading for the complementary region. */
14
+ title: ReactNode;
15
+ render(context: RightPanelRenderContext): ReactNode;
16
+ }
17
+
18
+ export interface RightPanelContextValue {
19
+ descriptor: RightPanelDescriptor | null;
20
+ isOpen: boolean;
21
+ open(descriptor: RightPanelDescriptor, launcher?: HTMLElement | null): void;
22
+ close(): void;
23
+ }
24
+
25
+ const RightPanelContext = createContext<RightPanelContextValue | null>(null);
26
+
27
+ export function RightPanelProvider({ children }: { children: ReactNode }) {
28
+ const [descriptor, setDescriptor] = useState<RightPanelDescriptor | null>(null);
29
+ const launcherRef = useRef<HTMLElement | null>(null);
30
+ const descriptorRef = useRef<RightPanelDescriptor | null>(null);
31
+ descriptorRef.current = descriptor;
32
+
33
+ const open = useCallback((next: RightPanelDescriptor, launcher?: HTMLElement | null) => {
34
+ if (descriptorRef.current?.key === next.key) return;
35
+ const active = typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
36
+ ? document.activeElement
37
+ : null;
38
+ launcherRef.current = launcher ?? active;
39
+ descriptorRef.current = next;
40
+ setDescriptor(next);
41
+ }, []);
42
+
43
+ const close = useCallback(() => {
44
+ if (descriptorRef.current == null) return;
45
+ descriptorRef.current = null;
46
+ setDescriptor(null);
47
+ const launcher = launcherRef.current;
48
+ launcherRef.current = null;
49
+ requestAnimationFrame(() => {
50
+ if (launcher?.isConnected) launcher.focus();
51
+ });
52
+ }, []);
53
+
54
+ useEffect(() => {
55
+ if (!descriptor) return;
56
+ const onKeyDown = (event: KeyboardEvent) => {
57
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
58
+ // Native dialogs own Escape while they are in the top layer.
59
+ if (document.querySelector('dialog[open]')) return;
60
+ event.preventDefault();
61
+ close();
62
+ };
63
+ document.addEventListener('keydown', onKeyDown);
64
+ return () => document.removeEventListener('keydown', onKeyDown);
65
+ }, [descriptor, close]);
66
+
67
+ return (
68
+ <RightPanelContext.Provider value={{ descriptor, isOpen: descriptor != null, open, close }}>
69
+ {children}
70
+ </RightPanelContext.Provider>
71
+ );
72
+ }
73
+
74
+ export function useRightPanel(): RightPanelContextValue {
75
+ const context = useContext(RightPanelContext);
76
+ if (!context) throw new Error('useRightPanel must be used inside <RightPanelProvider>');
77
+ return context;
78
+ }
79
+
80
+ export function RightPanelSlot({ className }: { className?: string }) {
81
+ const { descriptor, close } = useRightPanel();
82
+ const uid = useId();
83
+ const headingId = `right-panel-heading-${uid}`;
84
+ const closeRef = useRef<HTMLButtonElement>(null);
85
+
86
+ useLayoutEffect(() => {
87
+ if (!descriptor) return;
88
+ closeRef.current?.focus();
89
+ }, [descriptor?.key]);
90
+
91
+ if (!descriptor) return null;
92
+ return (
93
+ <aside
94
+ className={`right-panel${className ? ` ${className}` : ''}`}
95
+ role="complementary"
96
+ aria-labelledby={headingId}
97
+ >
98
+ <header className="right-panel-head">
99
+ <h2 id={headingId} tabIndex={-1}>{descriptor.title}</h2>
100
+ <button ref={closeRef} type="button" className="btn-icon" onClick={close} aria-label="Close panel" title="Close panel">
101
+ <i className="bi bi-x-lg" aria-hidden="true" />
102
+ </button>
103
+ </header>
104
+ <div className="right-panel-body">{descriptor.render({ close })}</div>
105
+ </aside>
106
+ );
107
+ }
@@ -0,0 +1,330 @@
1
+ // SidebarNav — renders the ACTIVE menu from the registry (menu-registry.ts).
2
+ // Static menus keep A4's original always-visible children. Menus that opt into
3
+ // `presentation: 'accordion'` get one-open domain groups, navigation search,
4
+ // active-branch expansion, and an app-owned icon-rail presentation.
5
+ import {
6
+ useContext, useEffect, useId, useMemo, useState, useSyncExternalStore,
7
+ type Dispatch, type SetStateAction,
8
+ } from 'react';
9
+ import { NavLink, useLocation } from 'react-router-dom';
10
+ import { useMe } from '../client/me';
11
+ import { GroupContext } from '../client/group-context';
12
+ import {
13
+ itemContainsRoute, itemVisible, menusVersion, resolveActiveMenu,
14
+ routesMatch, subscribeMenus, visibleMenuChildren,
15
+ type MenuConfig, type MenuContext, type MenuItem,
16
+ } from './menu-registry';
17
+
18
+ /** The active menu + context, derived — no menu state to desync. */
19
+ export function useSidebarMenu(): { menu: MenuConfig | null; ctx: MenuContext } {
20
+ useSyncExternalStore(subscribeMenus, menusVersion, menusVersion);
21
+ const { pathname } = useLocation();
22
+ const { data: me } = useMe();
23
+ const groupCtx = useContext(GroupContext);
24
+ const ctx: MenuContext = {
25
+ me: me ?? null,
26
+ member: groupCtx?.member ?? null,
27
+ group: groupCtx?.group ?? null,
28
+ };
29
+ return { menu: resolveActiveMenu(pathname, ctx), ctx };
30
+ }
31
+
32
+ function itemKey(item: MenuItem, index: number, parent = 'root'): string {
33
+ return item.id ?? item.route ?? `${parent}:${item.label ?? item.divider ?? index}`;
34
+ }
35
+
36
+ function domToken(value: string): string {
37
+ return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
38
+ }
39
+
40
+ /** Remove denied/empty branches while retaining the public MenuItem shape. */
41
+ function visibleTree(item: MenuItem, ctx: MenuContext): MenuItem | null {
42
+ if (!itemVisible(item, ctx)) return null;
43
+ if (item.divider) return item;
44
+ const hadChildren = item.children != null;
45
+ const children = visibleMenuChildren(item, ctx)
46
+ .map((child) => visibleTree(child, ctx))
47
+ .filter((child): child is MenuItem => child != null);
48
+ if (!item.route && hadChildren && children.length === 0) return null;
49
+ return hadChildren ? { ...item, children } : item;
50
+ }
51
+
52
+ /** Drop section dividers whose complete visible section is empty. */
53
+ function visibleRootItems(items: MenuItem[], ctx: MenuContext): MenuItem[] {
54
+ const out: MenuItem[] = [];
55
+ let pendingDivider: MenuItem | null = null;
56
+ for (const item of items) {
57
+ if (item.divider) {
58
+ pendingDivider = item;
59
+ continue;
60
+ }
61
+ const visible = visibleTree(item, ctx);
62
+ if (!visible) continue;
63
+ if (pendingDivider) {
64
+ out.push(pendingDivider);
65
+ pendingDivider = null;
66
+ }
67
+ out.push(visible);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function filterTree(item: MenuItem, query: string): MenuItem | null {
73
+ if (item.divider) return null;
74
+ const children = (item.children ?? [])
75
+ .map((child) => filterTree(child, query))
76
+ .filter((child): child is MenuItem => child != null);
77
+ const selfMatch = [item.label ?? '', ...(item.keywords ?? [])]
78
+ .some((value) => value.toLocaleLowerCase().includes(query));
79
+ if (!selfMatch && children.length === 0) return null;
80
+ return item.children ? { ...item, children: selfMatch ? item.children : children } : item;
81
+ }
82
+
83
+ function countDestinations(items: MenuItem[]): number {
84
+ return items.reduce((total, item) => total
85
+ + (item.route ? 1 : 0)
86
+ + countDestinations(item.children ?? []), 0);
87
+ }
88
+
89
+ function treeOwnsRoute(item: MenuItem, pathname: string): boolean {
90
+ if (item.route && routesMatch(item.route, pathname)) return true;
91
+ return (item.children ?? []).some((child) => treeOwnsRoute(child, pathname));
92
+ }
93
+
94
+ function NavRow({
95
+ item, pathname, depth, collapsed = false,
96
+ }: {
97
+ item: MenuItem;
98
+ pathname: string;
99
+ depth: number;
100
+ collapsed?: boolean;
101
+ }) {
102
+ if (!item.route) return null;
103
+ const active = routesMatch(item.route, pathname);
104
+ const label = item.label ?? item.route;
105
+ return (
106
+ <NavLink
107
+ to={item.route}
108
+ end={item.route === '/'}
109
+ aria-label={collapsed ? label : undefined}
110
+ data-tooltip={collapsed ? label : undefined}
111
+ className={`nav-item${depth > 0 ? ' nav-child' : ''}${active ? ' nav-active' : ''}`}
112
+ >
113
+ {item.icon && <i className={`bi ${item.icon}`} />}
114
+ <span className="nav-text">{label}</span>
115
+ {item.badge && <span className={`chip chip-${item.badge.tone ?? 'muted'} nav-badge`}>{item.badge.text}</span>}
116
+ </NavLink>
117
+ );
118
+ }
119
+
120
+ function StaticItem({
121
+ item, pathname, depth, parentKey, index,
122
+ }: {
123
+ item: MenuItem;
124
+ pathname: string;
125
+ depth: number;
126
+ parentKey: string;
127
+ index: number;
128
+ }) {
129
+ if (item.divider) return <div className="side-label">{item.divider}</div>;
130
+ const children = item.children ?? [];
131
+ if (!item.route) {
132
+ return (
133
+ <div className="nav-static-group">
134
+ {item.label && <div className="side-label">{item.label}</div>}
135
+ {children.map((child, childIndex) => (
136
+ <StaticItem
137
+ key={itemKey(child, childIndex, itemKey(item, index, parentKey))}
138
+ item={child}
139
+ pathname={pathname}
140
+ depth={depth + 1}
141
+ parentKey={itemKey(item, index, parentKey)}
142
+ index={childIndex}
143
+ />
144
+ ))}
145
+ </div>
146
+ );
147
+ }
148
+ return (
149
+ <>
150
+ <NavRow item={item} pathname={pathname} depth={depth} />
151
+ {children.map((child, childIndex) => (
152
+ <StaticItem
153
+ key={itemKey(child, childIndex, itemKey(item, index, parentKey))}
154
+ item={child}
155
+ pathname={pathname}
156
+ depth={depth + 1}
157
+ parentKey={itemKey(item, index, parentKey)}
158
+ index={childIndex}
159
+ />
160
+ ))}
161
+ </>
162
+ );
163
+ }
164
+
165
+ function AccordionItem({
166
+ item, index, pathname, openKey, setOpenKey, searching, collapsed,
167
+ onRequestExpand, idPrefix,
168
+ }: {
169
+ item: MenuItem;
170
+ index: number;
171
+ pathname: string;
172
+ openKey: string | null;
173
+ setOpenKey: Dispatch<SetStateAction<string | null>>;
174
+ searching: boolean;
175
+ collapsed: boolean;
176
+ onRequestExpand?: () => void;
177
+ idPrefix: string;
178
+ }) {
179
+ if (item.divider) return collapsed ? null : <div className="side-label">{item.divider}</div>;
180
+ const children = item.children ?? [];
181
+ if (item.route || children.length === 0) {
182
+ return <NavRow item={item} pathname={pathname} depth={0} collapsed={collapsed} />;
183
+ }
184
+
185
+ const key = itemKey(item, index);
186
+ const active = treeOwnsRoute(item, pathname);
187
+ const open = searching || openKey === key;
188
+ const disclosureId = `${idPrefix}-${domToken(key)}`;
189
+ const label = item.label ?? key;
190
+ const activate = () => {
191
+ if (collapsed) {
192
+ setOpenKey(key);
193
+ onRequestExpand?.();
194
+ } else {
195
+ setOpenKey((current) => current === key ? null : key);
196
+ }
197
+ };
198
+
199
+ return (
200
+ <div className={`nav-accordion${open ? ' nav-accordion-open' : ''}`}>
201
+ <button
202
+ type="button"
203
+ className={`nav-item nav-parent${active ? ' nav-active' : ''}`}
204
+ aria-expanded={collapsed ? undefined : open}
205
+ aria-controls={collapsed ? undefined : disclosureId}
206
+ aria-label={collapsed ? label : undefined}
207
+ data-tooltip={collapsed ? label : undefined}
208
+ onClick={activate}
209
+ onKeyDown={(event) => {
210
+ if (collapsed && (event.key === 'Enter' || event.key === ' ')) {
211
+ event.preventDefault();
212
+ activate();
213
+ }
214
+ }}
215
+ >
216
+ {item.icon && <i className={`bi ${item.icon}`} />}
217
+ <span className="nav-text">{label}</span>
218
+ {item.badge && <span className={`chip chip-${item.badge.tone ?? 'muted'} nav-badge`}>{item.badge.text}</span>}
219
+ {!collapsed && <i className="bi bi-chevron-down nav-arrow" aria-hidden="true" />}
220
+ </button>
221
+ {!collapsed && (
222
+ <div id={disclosureId} className="nav-submenu" hidden={!open}>
223
+ {children.map((child, childIndex) => (
224
+ child.route
225
+ ? <NavRow key={itemKey(child, childIndex, key)} item={child} pathname={pathname} depth={1} />
226
+ : <StaticItem
227
+ key={itemKey(child, childIndex, key)}
228
+ item={child}
229
+ pathname={pathname}
230
+ depth={1}
231
+ parentKey={key}
232
+ index={childIndex}
233
+ />
234
+ ))}
235
+ </div>
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ export interface SidebarNavProps {
242
+ /** App-owned icon-rail state. Layout width remains the app's concern. */
243
+ collapsed?: boolean;
244
+ /** Called when a rail category is activated so the shell can expand. */
245
+ onRequestExpand?: () => void;
246
+ }
247
+
248
+ export function SidebarNav({ collapsed = false, onRequestExpand }: SidebarNavProps = {}) {
249
+ const { menu, ctx } = useSidebarMenu();
250
+ const { pathname } = useLocation();
251
+ const disclosurePrefix = domToken(useId());
252
+ const [openKey, setOpenKey] = useState<string | null>(null);
253
+ const [query, setQuery] = useState('');
254
+
255
+ const items = useMemo(
256
+ () => menu ? visibleRootItems(menu.items, ctx) : [],
257
+ [menu, ctx.me, ctx.member, ctx.group],
258
+ );
259
+ const activeKey = useMemo(() => {
260
+ const index = items.findIndex((item) => !item.divider && itemContainsRoute(item, pathname, ctx));
261
+ return index < 0 ? null : itemKey(items[index]!, index);
262
+ }, [items, pathname, ctx]);
263
+
264
+ useEffect(() => {
265
+ setOpenKey(activeKey);
266
+ }, [activeKey, menu?.name]);
267
+
268
+ const normalizedQuery = query.trim().toLocaleLowerCase();
269
+ const filteredItems = useMemo(() => normalizedQuery
270
+ ? items.map((item) => filterTree(item, normalizedQuery)).filter((item): item is MenuItem => item != null)
271
+ : items, [items, normalizedQuery]);
272
+ const resultCount = countDestinations(filteredItems);
273
+
274
+ if (!menu) return null;
275
+ if (menu.presentation !== 'accordion') {
276
+ return (
277
+ <nav className="side-nav" data-menu={menu.name}>
278
+ {items.map((item, index) => (
279
+ <StaticItem
280
+ key={itemKey(item, index)} item={item} pathname={pathname}
281
+ depth={0} parentKey="root" index={index}
282
+ />
283
+ ))}
284
+ </nav>
285
+ );
286
+ }
287
+
288
+ return (
289
+ <nav className={`side-nav side-nav-accordion${collapsed ? ' side-nav-collapsed' : ''}`} data-menu={menu.name} aria-label="Admin navigation">
290
+ {!collapsed && (
291
+ <div className="nav-search">
292
+ <i className="bi bi-search" aria-hidden="true" />
293
+ <input
294
+ type="search"
295
+ value={query}
296
+ aria-label="Search Admin navigation"
297
+ placeholder="Find admin page…"
298
+ onChange={(event) => setQuery(event.target.value)}
299
+ onKeyDown={(event) => {
300
+ if (event.key === 'Escape' && query) {
301
+ event.preventDefault();
302
+ setQuery('');
303
+ }
304
+ }}
305
+ />
306
+ {query && <button type="button" aria-label="Clear navigation search" onClick={() => setQuery('')}><i className="bi bi-x-lg" /></button>}
307
+ </div>
308
+ )}
309
+ {!collapsed && normalizedQuery && (
310
+ <div className="nav-search-status" role="status" aria-live="polite">
311
+ {resultCount === 0 ? 'No admin pages found' : `${resultCount} admin page${resultCount === 1 ? '' : 's'} found`}
312
+ </div>
313
+ )}
314
+ {filteredItems.map((item, index) => (
315
+ <AccordionItem
316
+ key={itemKey(item, index)}
317
+ item={item}
318
+ index={index}
319
+ pathname={pathname}
320
+ openKey={openKey}
321
+ setOpenKey={setOpenKey}
322
+ searching={Boolean(normalizedQuery)}
323
+ collapsed={collapsed}
324
+ onRequestExpand={onRequestExpand}
325
+ idPrefix={disclosurePrefix}
326
+ />
327
+ ))}
328
+ </nav>
329
+ );
330
+ }
@@ -0,0 +1,161 @@
1
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
2
+
3
+ export type TabVariant = 'minimal' | 'traditional' | 'underline' | 'underline-all' | 'pills' | 'pills-solid' | 'segmented' | 'btn-group';
4
+
5
+ export interface TabItem {
6
+ key: string;
7
+ label: ReactNode;
8
+ panel: ReactNode;
9
+ disabled?: boolean;
10
+ ariaLabel?: string;
11
+ }
12
+
13
+ export interface TabsProps {
14
+ items: TabItem[];
15
+ activeKey?: string | null;
16
+ defaultActiveKey?: string | null;
17
+ onActiveKeyChange?: (key: string) => void;
18
+ variant?: TabVariant | 'buttongroup' | 'btngroup' | (string & {});
19
+ ariaLabel?: string;
20
+ className?: string;
21
+ }
22
+
23
+ const VARIANTS = new Set<TabVariant>(['minimal', 'traditional', 'underline', 'underline-all', 'pills', 'pills-solid', 'segmented', 'btn-group']);
24
+ const warned = new Set<string>();
25
+
26
+ function warnOnce(key: string, message: string): void {
27
+ if (warned.has(key)) return;
28
+ warned.add(key);
29
+ console.warn(message);
30
+ }
31
+
32
+ export function normalizeTabVariant(value: string | undefined): TabVariant {
33
+ if (value === 'buttongroup' || value === 'btngroup') return 'btn-group';
34
+ if (!value) return 'underline-all';
35
+ if (VARIANTS.has(value as TabVariant)) return value as TabVariant;
36
+ warnOnce(`variant:${value}`, `Tabs: unknown variant "${value}" — falling back to "underline-all"`);
37
+ return 'underline-all';
38
+ }
39
+
40
+ export function normalizeTabItems(items: TabItem[]): TabItem[] {
41
+ const seen = new Set<string>();
42
+ return items.filter((item) => {
43
+ if (!item.key || seen.has(item.key)) {
44
+ warnOnce(`key:${item.key || '<empty>'}`, `Tabs: ${item.key ? `duplicate key "${item.key}"` : 'empty key'} ignored`);
45
+ return false;
46
+ }
47
+ seen.add(item.key);
48
+ return true;
49
+ });
50
+ }
51
+
52
+ export function effectiveTabKey(requested: string | null | undefined, items: TabItem[]): string | null {
53
+ const enabled = items.filter((item) => !item.disabled);
54
+ if (requested && enabled.some((item) => item.key === requested)) return requested;
55
+ return enabled[0]?.key ?? null;
56
+ }
57
+
58
+ export function nextTabKey(items: TabItem[], current: string | null, key: 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End'): string | null {
59
+ const enabled = items.filter((item) => !item.disabled);
60
+ if (!enabled.length) return null;
61
+ if (key === 'Home') return enabled[0]!.key;
62
+ if (key === 'End') return enabled[enabled.length - 1]!.key;
63
+ const at = Math.max(0, enabled.findIndex((item) => item.key === current));
64
+ const delta = key === 'ArrowRight' ? 1 : -1;
65
+ return enabled[(at + delta + enabled.length) % enabled.length]!.key;
66
+ }
67
+
68
+ export function controlledTabHealTransition(
69
+ previousNotice: string,
70
+ requested: string | null | undefined,
71
+ effective: string | null,
72
+ items: TabItem[],
73
+ ): { nextNotice: string; notify: string | null } {
74
+ // A valid request closes the prior invalid episode. If that same invalid
75
+ // request returns later, it is a new transition and must notify again.
76
+ if (requested === effective || effective == null) return { nextNotice: '', notify: null };
77
+ const notice = `${String(requested)}|${items.filter((item) => !item.disabled).map((item) => item.key).join('|')}|${effective}`;
78
+ if (notice === previousNotice) return { nextNotice: previousNotice, notify: null };
79
+ return { nextNotice: notice, notify: effective };
80
+ }
81
+
82
+ export function Tabs({ items, activeKey, defaultActiveKey, onActiveKeyChange, variant, ariaLabel = 'Tabs', className }: TabsProps) {
83
+ const normalized = useMemo(() => normalizeTabItems(items), [items]);
84
+ const controlled = activeKey !== undefined;
85
+ const [internalKey, setInternalKey] = useState<string | null>(() => effectiveTabKey(defaultActiveKey, normalized));
86
+ const requested = controlled ? activeKey : internalKey;
87
+ const effective = effectiveTabKey(requested, normalized);
88
+ const uid = useId().replace(/:/g, '');
89
+ const refs = useRef(new Map<string, HTMLButtonElement>());
90
+ const lastNotice = useRef('');
91
+
92
+ useEffect(() => {
93
+ if (!controlled && internalKey !== effective) setInternalKey(effective);
94
+ }, [controlled, internalKey, effective]);
95
+
96
+ useEffect(() => {
97
+ if (!controlled) {
98
+ lastNotice.current = '';
99
+ return;
100
+ }
101
+ const transition = controlledTabHealTransition(lastNotice.current, requested, effective, normalized);
102
+ lastNotice.current = transition.nextNotice;
103
+ if (transition.notify) onActiveKeyChange?.(transition.notify);
104
+ }, [controlled, requested, effective, normalized, onActiveKeyChange]);
105
+
106
+ const select = (key: string, focus = false) => {
107
+ if (!controlled) setInternalKey(key);
108
+ onActiveKeyChange?.(key);
109
+ if (focus) window.setTimeout(() => refs.current.get(key)?.focus(), 0);
110
+ };
111
+
112
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
113
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
114
+ event.preventDefault();
115
+ const next = nextTabKey(normalized, effective, event.key as 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End');
116
+ if (next) select(next, true);
117
+ };
118
+
119
+ const variantName = normalizeTabVariant(variant);
120
+ return (
121
+ <div className={className ? `mojo-tabs ${className}` : 'mojo-tabs'} data-tab-variant={variantName}>
122
+ <div className="mojo-tab-list" role="tablist" aria-label={ariaLabel}>
123
+ {normalized.map((item, index) => {
124
+ const selected = item.key === effective;
125
+ const tabId = `tab-${uid}-${index}`;
126
+ const panelId = `panel-${uid}-${index}`;
127
+ return (
128
+ <button
129
+ ref={(node) => { if (node) refs.current.set(item.key, node); else refs.current.delete(item.key); }}
130
+ key={item.key}
131
+ id={tabId}
132
+ type="button"
133
+ role="tab"
134
+ aria-label={item.ariaLabel}
135
+ aria-selected={selected}
136
+ aria-disabled={item.disabled || undefined}
137
+ aria-controls={panelId}
138
+ tabIndex={selected ? 0 : -1}
139
+ disabled={item.disabled}
140
+ className="mojo-tab"
141
+ onClick={() => select(item.key)}
142
+ onKeyDown={onKeyDown}
143
+ >
144
+ {item.label}
145
+ </button>
146
+ );
147
+ })}
148
+ </div>
149
+ <div className="mojo-tab-panels">
150
+ {normalized.map((item, index) => {
151
+ const selected = item.key === effective;
152
+ return (
153
+ <div key={item.key} id={`panel-${uid}-${index}`} role="tabpanel" aria-labelledby={`tab-${uid}-${index}`} hidden={!selected} className="mojo-tab-panel">
154
+ {selected ? item.panel : null}
155
+ </div>
156
+ );
157
+ })}
158
+ </div>
159
+ </div>
160
+ );
161
+ }