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,336 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
+ import type { Field, FormData } from '../client/types';
3
+ import { modal, type ModalSize } from './modal';
4
+ import { firstErrorName, SchemaFieldGrid, useSchemaFormState } from './schema-form-core';
5
+ import { Tabs, type TabVariant } from './Tabs';
6
+
7
+ export interface FormWizardSection {
8
+ key: string;
9
+ label: ReactNode;
10
+ fields: Field[];
11
+ description?: ReactNode;
12
+ optional?: boolean;
13
+ /** Custom content shares the same draft as schema fields. */
14
+ content?: ReactNode | ((context: FormWizardSectionContext) => ReactNode);
15
+ /** Removes a step without discarding its draft. */
16
+ eligible?: (data: FormData) => boolean;
17
+ /** Runs after validation and before advancing. Rejections stay on this step. */
18
+ onNext?: (data: FormData) => void | Promise<void>;
19
+ /** A receipt/status step with no Back/Next/Finish action row. */
20
+ terminal?: boolean;
21
+ }
22
+
23
+ export interface FormWizardSectionContext {
24
+ data: FormData;
25
+ busy: boolean;
26
+ setValue: (name: string, value: FormData[string]) => void;
27
+ patchValues: (patch: FormData) => void;
28
+ }
29
+
30
+ export interface FormWizardProps {
31
+ sections: FormWizardSection[];
32
+ initial?: FormData;
33
+ resetKey?: unknown;
34
+ mode: 'wizard' | 'tabs';
35
+ tabVariant?: TabVariant | 'buttongroup' | 'btngroup' | (string & {});
36
+ backText?: string;
37
+ nextText?: string;
38
+ finishText?: string;
39
+ saveText?: string;
40
+ cancelText?: string;
41
+ busyText?: string;
42
+ validateAllOnFinish?: boolean;
43
+ onStepChange?: (sectionKey: string) => void;
44
+ onBusyChange?: (busy: boolean) => void;
45
+ onCancel?: () => void;
46
+ /** Return the authoritative owner row when a section contains File relations. */
47
+ onFinish: (data: FormData) => unknown | Promise<unknown>;
48
+ className?: string;
49
+ }
50
+
51
+ const warnedWizard = new Set<string>();
52
+ function warnWizardOnce(key: string, message: string): void {
53
+ if (warnedWizard.has(key)) return;
54
+ warnedWizard.add(key);
55
+ console.warn(message);
56
+ }
57
+
58
+ export function normalizeWizardMode(mode: string): 'wizard' | 'tabs' {
59
+ if (mode === 'wizard' || mode === 'tabs') return mode;
60
+ warnWizardOnce(`mode:${mode}`, `FormWizard: unknown mode "${mode}" — falling back to "tabs"`);
61
+ return 'tabs';
62
+ }
63
+
64
+ export function normalizeWizardSections(sections: FormWizardSection[]): FormWizardSection[] {
65
+ const sectionKeys = new Set<string>();
66
+ const fieldNames = new Set<string>();
67
+ const result: FormWizardSection[] = [];
68
+ for (const section of sections) {
69
+ if (!section.key || sectionKeys.has(section.key)) {
70
+ warnWizardOnce(`section:${section.key || '<empty>'}`, `FormWizard: ${section.key ? `duplicate section key "${section.key}"` : 'empty section key'} ignored`);
71
+ continue;
72
+ }
73
+ sectionKeys.add(section.key);
74
+ const fields = section.fields.filter((field) => {
75
+ if (!field.name || fieldNames.has(field.name)) {
76
+ warnWizardOnce(`field:${field.name || '<empty>'}`, `FormWizard: ${field.name ? `duplicate field name "${field.name}"` : 'empty field name'} ignored`);
77
+ return false;
78
+ }
79
+ fieldNames.add(field.name);
80
+ return true;
81
+ });
82
+ result.push({ ...section, fields });
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function healWizardSection(activeKey: string | null, sections: FormWizardSection[]): string | null {
88
+ return sections.some((section) => section.key === activeKey) ? activeKey : sections[0]?.key ?? null;
89
+ }
90
+
91
+ export function createWizardSingleFlight() {
92
+ let pending = false;
93
+ return {
94
+ get pending() { return pending; },
95
+ async run<T>(task: () => T | Promise<T>): Promise<T | undefined> {
96
+ if (pending) return undefined;
97
+ pending = true;
98
+ try {
99
+ return await task();
100
+ } finally {
101
+ pending = false;
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ export function FormWizard(props: FormWizardProps) {
108
+ const {
109
+ sections: rawSections,
110
+ initial = {},
111
+ resetKey,
112
+ tabVariant,
113
+ onStepChange,
114
+ onCancel,
115
+ onFinish,
116
+ className,
117
+ } = props;
118
+ const normalizedSections = useMemo(() => normalizeWizardSections(rawSections), [rawSections]);
119
+ const fields = useMemo(() => normalizedSections.flatMap((section) => section.fields), [normalizedSections]);
120
+ const mode = normalizeWizardMode(props.mode as string);
121
+ const [activeKey, setActiveKey] = useState<string | null>(() => normalizedSections[0]?.key ?? null);
122
+ const activeKeyRef = useRef(activeKey);
123
+ activeKeyRef.current = activeKey;
124
+ const [busy, setBusy] = useState(false);
125
+ const setWizardBusy = (next: boolean) => {
126
+ props.onBusyChange?.(next);
127
+ setBusy(next);
128
+ };
129
+ const finishFlight = useRef(createWizardSingleFlight());
130
+ const transitionFlight = useRef(createWizardSingleFlight());
131
+ const generation = useRef(0);
132
+ const mounted = useRef(true);
133
+ const resetRef = useRef(resetKey);
134
+ const form = useSchemaFormState({ fields, initial, profile: 'wizard', reconcile: true, resetKey, deferReconcile: busy });
135
+ const sections = normalizedSections.filter((section) => section.eligible?.(form.values) !== false);
136
+ const locked = busy || form.uploadPending;
137
+
138
+ useEffect(() => props.onBusyChange?.(locked), [locked, props.onBusyChange]);
139
+
140
+ useEffect(() => {
141
+ generation.current += 1;
142
+ transitionFlight.current = createWizardSingleFlight();
143
+ if (busy) setWizardBusy(false);
144
+ // resetKey is the generation boundary; busy changes must not create one.
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [resetKey]);
147
+
148
+ useEffect(() => () => {
149
+ mounted.current = false;
150
+ generation.current += 1;
151
+ }, []);
152
+
153
+ useEffect(() => {
154
+ if (busy) return;
155
+ const resetChanged = !Object.is(resetRef.current, resetKey);
156
+ const healed = resetChanged ? sections[0]?.key ?? null : healWizardSection(activeKey, sections);
157
+ resetRef.current = resetKey;
158
+ if (healed !== activeKeyRef.current) {
159
+ activeKeyRef.current = healed;
160
+ setActiveKey(healed);
161
+ if (!resetChanged && healed) onStepChange?.(healed);
162
+ }
163
+ }, [activeKey, busy, onStepChange, resetKey, sections]);
164
+
165
+ const activeIndex = Math.max(0, sections.findIndex((section) => section.key === activeKey));
166
+ const activeSection = sections[activeIndex] ?? null;
167
+
168
+ const go = (key: string) => {
169
+ if (locked || key === activeKeyRef.current || !sections.some((section) => section.key === key)) return;
170
+ activeKeyRef.current = key;
171
+ setActiveKey(key);
172
+ onStepChange?.(key);
173
+ };
174
+
175
+ const validateAndFocus = (targetFields: Field[], sectionPool: FormWizardSection[]): boolean => {
176
+ const errors = form.validate(targetFields);
177
+ const firstName = firstErrorName(targetFields, errors);
178
+ if (!firstName) return true;
179
+ const targetSection = sectionPool.find((section) => section.fields.some((field) => field.name === firstName));
180
+ if (targetSection && targetSection.key !== activeKey) setActiveKey(targetSection.key);
181
+ form.focusField(firstName);
182
+ return false;
183
+ };
184
+
185
+ const next = async () => {
186
+ if (form.uploadPending) return;
187
+ if (transitionFlight.current.pending) return;
188
+ if (!activeSection || !validateAndFocus(activeSection.fields, [activeSection])) return;
189
+ const following = sections[activeIndex + 1];
190
+ if (!following) return;
191
+ const acceptedGeneration = generation.current;
192
+ if (!activeSection.onNext) {
193
+ go(following.key);
194
+ return;
195
+ }
196
+ setWizardBusy(true);
197
+ form.setFormError('');
198
+ try {
199
+ await transitionFlight.current.run(() => activeSection.onNext!(form.payload(fields)));
200
+ if (mounted.current && generation.current === acceptedGeneration) go(following.key);
201
+ } catch (error) {
202
+ if (mounted.current && generation.current === acceptedGeneration) {
203
+ form.setFormError(error instanceof Error ? error.message : 'Could not continue');
204
+ }
205
+ } finally {
206
+ if (mounted.current && generation.current === acceptedGeneration) setWizardBusy(false);
207
+ }
208
+ };
209
+
210
+ const finish = async () => {
211
+ if (form.uploadPending) return;
212
+ if (finishFlight.current.pending || !activeSection) return;
213
+ const validationSections = props.validateAllOnFinish === false ? [activeSection] : sections;
214
+ const validationFields = validationSections.flatMap((section) => section.fields);
215
+ if (!validateAndFocus(validationFields, validationSections)) return;
216
+
217
+ // Capture the accepted roster and payload before any parent update.
218
+ const acceptedFields = [...fields];
219
+ const payload = form.payload(acceptedFields);
220
+ const acceptedGeneration = generation.current;
221
+ setWizardBusy(true);
222
+ form.setFormError('');
223
+ try {
224
+ const row = await finishFlight.current.run(() => onFinish(payload));
225
+ form.settleOwnerSave(row, false, payload);
226
+ } catch (error) {
227
+ form.settleOwnerSave(undefined, true, payload);
228
+ if (mounted.current && generation.current === acceptedGeneration) form.setFormError(error instanceof Error ? error.message : 'Save failed');
229
+ } finally {
230
+ if (mounted.current && generation.current === acceptedGeneration) setWizardBusy(false);
231
+ }
232
+ };
233
+
234
+ if (!sections.length) {
235
+ return <div className={className ? `form-wizard ${className}` : 'form-wizard'}><div className="form-wizard-empty">No sections available.</div></div>;
236
+ }
237
+
238
+ const sectionPanel = (section: FormWizardSection) => (
239
+ <div className="form-wizard-section">
240
+ {section.description && <div className="form-wizard-description">{section.description}</div>}
241
+ <SchemaFieldGrid fields={section.fields} state={form} disabled={busy} />
242
+ {typeof section.content === 'function' ? section.content({
243
+ data: form.values, busy: locked, setValue: form.setValue, patchValues: form.patchValues,
244
+ }) : section.content}
245
+ </div>
246
+ );
247
+
248
+ return (
249
+ <div className={className ? `form-wizard ${className}` : 'form-wizard'} aria-busy={locked || undefined}>
250
+ {form.formError && <div className="form-alert" role="alert">{form.formError}</div>}
251
+ {mode === 'tabs' ? (
252
+ <Tabs
253
+ variant={tabVariant}
254
+ ariaLabel="Form sections"
255
+ activeKey={activeKey}
256
+ onActiveKeyChange={go}
257
+ items={sections.map((section) => ({
258
+ key: section.key,
259
+ label: <>{section.label}{section.optional && <span className="form-wizard-optional"> Optional</span>}</>,
260
+ disabled: busy || form.uploadPending,
261
+ panel: sectionPanel(section),
262
+ }))}
263
+ />
264
+ ) : (
265
+ <>
266
+ <ol className="form-wizard-progress" aria-label="Form progress">
267
+ {sections.map((section, index) => (
268
+ <li key={section.key} className={index === activeIndex ? 'is-active' : index < activeIndex ? 'is-complete' : ''} aria-current={index === activeIndex ? 'step' : undefined}>
269
+ <span className="form-wizard-step-number">{index + 1}</span>
270
+ <span>{section.label}</span>
271
+ </li>
272
+ ))}
273
+ </ol>
274
+ {activeSection && sectionPanel(activeSection)}
275
+ </>
276
+ )}
277
+ {!activeSection?.terminal && <div className="modal-actions form-wizard-actions">
278
+ {onCancel && <button type="button" className="btn" disabled={locked} onClick={onCancel}>{props.cancelText ?? 'Cancel'}</button>}
279
+ {mode === 'wizard' && activeIndex > 0 && (
280
+ <button type="button" className="btn" disabled={locked} onClick={() => go(sections[activeIndex - 1]!.key)}>{props.backText ?? 'Back'}</button>
281
+ )}
282
+ {mode === 'wizard' && activeIndex < sections.length - 1 ? (
283
+ <button type="button" className="btn btn-primary" disabled={locked} onClick={() => void next()}>{props.nextText ?? 'Next'}</button>
284
+ ) : (
285
+ <button type="button" className="btn btn-primary" disabled={locked} onClick={finish}>
286
+ {busy ? props.busyText ?? 'Saving…' : mode === 'tabs' ? props.saveText ?? 'Save' : props.finishText ?? 'Finish'}
287
+ </button>
288
+ )}
289
+ </div>}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ export type FormWizardModalOptions = Omit<FormWizardProps, 'onCancel'> & { title: string; size?: ModalSize };
295
+
296
+ export function formWizardModal(options: FormWizardModalOptions): Promise<FormData | null> {
297
+ let pending = false;
298
+ let finishing = false;
299
+ let live = true;
300
+ return modal.open<FormData>((close) => (
301
+ <div className="modal-pad">
302
+ <h2 className="modal-title">{options.title}</h2>
303
+ <FormWizard
304
+ {...options}
305
+ onBusyChange={(busy) => {
306
+ pending = busy;
307
+ options.onBusyChange?.(busy);
308
+ }}
309
+ onCancel={() => {
310
+ if (pending) return;
311
+ live = false;
312
+ close(null as unknown as FormData);
313
+ }}
314
+ onFinish={async (data) => {
315
+ if (finishing) return;
316
+ finishing = true;
317
+ pending = true;
318
+ try {
319
+ const row = await options.onFinish(data);
320
+ if (live) {
321
+ live = false;
322
+ close(data);
323
+ }
324
+ return row;
325
+ } finally {
326
+ finishing = false;
327
+ pending = false;
328
+ }
329
+ }}
330
+ />
331
+ </div>
332
+ ), { size: options.size, canDismiss: () => !pending }).then((result) => {
333
+ live = false;
334
+ return result;
335
+ });
336
+ }
@@ -0,0 +1,202 @@
1
+ // GroupSwitcher — the searchable, hierarchical group selector. Ported from
2
+ // web-mojo GroupSearchView (tree build from flat rows incl. embedded parent
3
+ // objects, name-sorted levels, └/├/│ line segments, kind chips) on
4
+ // SimpleSearchView's mechanics (300ms debounced server-side search).
5
+ // Selection drives the A3 GroupProvider: storage + `?group=` URL param +
6
+ // member fetch all follow from setActiveGroup.
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ import { useActiveGroup } from '../client/group';
9
+ import type { Group } from '../client/group-context';
10
+ import { useModelList } from '../client/hooks';
11
+
12
+ interface FlatNode {
13
+ group: Group;
14
+ level: number;
15
+ isLastChild: boolean;
16
+ /** For each ancestor level: does its vertical line continue past this row? */
17
+ verticals: boolean[];
18
+ hasChildren: boolean;
19
+ }
20
+
21
+ /** GroupSearchView.buildTreeHierarchy + flattenTree + computeVerticalLines. */
22
+ export function buildGroupTree(items: Group[]): FlatNode[] {
23
+ if (items.length === 0) return [];
24
+ interface Node { group: Group; children: Node[] }
25
+ const byId = new Map<number, Node>();
26
+ // First pass: every row, plus parents embedded on rows (a matched child
27
+ // whose parent didn't match the search still renders under it).
28
+ for (const item of items) {
29
+ if (!byId.has(item.id)) byId.set(item.id, { group: item, children: [] });
30
+ }
31
+ for (const item of items) {
32
+ const parent = item.parent;
33
+ if (parent && parent.id && !byId.has(parent.id)) {
34
+ byId.set(parent.id, { group: { kind: 'group', ...parent } as Group, children: [] });
35
+ }
36
+ }
37
+ const roots: Node[] = [];
38
+ for (const node of byId.values()) {
39
+ const parentId = node.group.parent?.id;
40
+ if (parentId != null && byId.has(parentId) && parentId !== node.group.id) {
41
+ byId.get(parentId)!.children.push(node);
42
+ } else {
43
+ roots.push(node);
44
+ }
45
+ }
46
+ const byName = (a: Node, b: Node) => (a.group.name || '').localeCompare(b.group.name || '');
47
+ roots.sort(byName);
48
+
49
+ const flat: FlatNode[] = [];
50
+ const walk = (nodes: Node[], level: number, ancestorsLast: boolean[]) => {
51
+ nodes.sort(byName);
52
+ nodes.forEach((node, i) => {
53
+ const isLastChild = i === nodes.length - 1;
54
+ flat.push({ group: node.group, level, isLastChild, verticals: [], hasChildren: node.children.length > 0 });
55
+ if (node.children.length > 0) walk(node.children, level + 1, [...ancestorsLast, isLastChild]);
56
+ });
57
+ };
58
+ walk(roots, 0, []);
59
+
60
+ // Second pass: a vertical line continues at segment s when a later row at
61
+ // level s+1 appears before the walk returns to level ≤ s.
62
+ for (let i = 0; i < flat.length; i++) {
63
+ const node = flat[i]!;
64
+ for (let s = 0; s < node.level; s++) {
65
+ let found = false;
66
+ for (let j = i + 1; j < flat.length; j++) {
67
+ const later = flat[j]!;
68
+ if (later.level === s + 1) { found = true; break; }
69
+ if (later.level <= s) break;
70
+ }
71
+ node.verticals[s] = found;
72
+ }
73
+ }
74
+ return flat;
75
+ }
76
+
77
+ function TreeLines({ node }: { node: FlatNode }) {
78
+ if (node.level === 0) return null;
79
+ return (
80
+ <span className="tree-lines">
81
+ {Array.from({ length: node.level }, (_, s) => {
82
+ const last = s === node.level - 1;
83
+ const cls = last
84
+ ? (node.isLastChild ? 'tree-seg tree-seg-last' : 'tree-seg tree-seg-mid')
85
+ : (node.verticals[s] ? 'tree-seg tree-seg-vert' : 'tree-seg');
86
+ return <span key={s} className={cls} />;
87
+ })}
88
+ </span>
89
+ );
90
+ }
91
+
92
+ export function GroupSwitcher({ onSelected, onCleared }: {
93
+ /** After a group activates — e.g. navigate into the group menu's home. */
94
+ onSelected?: (group: Group) => void;
95
+ /** After the active group clears — e.g. navigate back to the main menu. */
96
+ onCleared?: () => void;
97
+ } = {}) {
98
+ const { group, loading, setActiveGroup, clearActiveGroup } = useActiveGroup();
99
+ const [open, setOpen] = useState(false);
100
+ const [input, setInput] = useState('');
101
+ const [term, setTerm] = useState('');
102
+ const wrapRef = useRef<HTMLDivElement>(null);
103
+ const searchRef = useRef<HTMLInputElement>(null);
104
+
105
+ // SimpleSearchView parity: 300ms debounce into the server-side search.
106
+ useEffect(() => {
107
+ const t = setTimeout(() => setTerm(input.trim()), 300);
108
+ return () => clearTimeout(t);
109
+ }, [input]);
110
+
111
+ useEffect(() => {
112
+ if (!open) return;
113
+ searchRef.current?.focus();
114
+ // 'click', not 'mousedown': a listener attached while the opening
115
+ // click is still dispatching never sees that same event, so the
116
+ // opener can't self-close (mousedown-arming raced automation/fast
117
+ // double-events into instant closes).
118
+ const onDocClick = (e: MouseEvent) => {
119
+ if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
120
+ };
121
+ const onKey = (e: KeyboardEvent) => {
122
+ if (e.key === 'Escape') setOpen(false);
123
+ };
124
+ document.addEventListener('click', onDocClick);
125
+ document.addEventListener('keydown', onKey);
126
+ return () => {
127
+ document.removeEventListener('click', onDocClick);
128
+ document.removeEventListener('keydown', onKey);
129
+ };
130
+ }, [open]);
131
+
132
+ const list = useModelList<Group>('/api/group', { search: term || undefined, sort: 'name', size: 50 });
133
+ const tree = useMemo(() => buildGroupTree(list.data?.rows ?? []), [list.data]);
134
+
135
+ const pick = (g: Group) => {
136
+ setActiveGroup(g);
137
+ setOpen(false);
138
+ setInput('');
139
+ onSelected?.(g);
140
+ };
141
+
142
+ return (
143
+ <div className="group-switch-wrap" ref={wrapRef}>
144
+ <button
145
+ className={`group-btn${group ? ' group-btn-active' : ''}`}
146
+ onClick={() => setOpen((v) => !v)}
147
+ aria-haspopup="listbox"
148
+ aria-expanded={open}
149
+ title={group ? `Active group: ${group.name}` : 'Select active group'}
150
+ >
151
+ <i className={`bi ${group ? 'bi-diagram-3-fill' : 'bi-diagram-3'}`} />
152
+ <span className="group-btn-label">
153
+ {loading ? 'Loading…' : group ? group.name : 'All groups'}
154
+ </span>
155
+ {group && <span className="chip chip-primary group-kind">{group.kind}</span>}
156
+ <i className="bi bi-chevron-expand group-caret" />
157
+ </button>
158
+ {open && (
159
+ <div className="group-menu" role="listbox">
160
+ <div className="search-box search-box-fill group-menu-search">
161
+ <i className="bi bi-search" />
162
+ <input
163
+ ref={searchRef}
164
+ value={input}
165
+ placeholder="Search groups…"
166
+ onChange={(e) => setInput(e.target.value)}
167
+ />
168
+ </div>
169
+ <div className="group-menu-list">
170
+ {group && (
171
+ <button className="group-row group-row-clear" onClick={() => { clearActiveGroup(); setOpen(false); onCleared?.(); }}>
172
+ <i className="bi bi-x-circle" /> Clear active group
173
+ </button>
174
+ )}
175
+ {list.isLoading && (
176
+ <div className="group-menu-loading">
177
+ <span className="skel" /><span className="skel" /><span className="skel" />
178
+ </div>
179
+ )}
180
+ {!list.isLoading && tree.length === 0 && (
181
+ <div className="group-menu-empty">No groups match “{term}”</div>
182
+ )}
183
+ {tree.map((node) => (
184
+ <button
185
+ key={node.group.id}
186
+ className={`group-row${group?.id === node.group.id ? ' group-row-active' : ''}`}
187
+ onClick={() => pick(node.group)}
188
+ role="option"
189
+ aria-selected={group?.id === node.group.id}
190
+ >
191
+ <TreeLines node={node} />
192
+ <span className="group-row-name">{node.group.name}</span>
193
+ <span className="group-row-kind">{node.group.kind}</span>
194
+ {group?.id === node.group.id && <i className="bi bi-check-lg" />}
195
+ </button>
196
+ ))}
197
+ </div>
198
+ </div>
199
+ )}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,16 @@
1
+ // Permission-gated slot. Fail-closed: renders the fallback (default nothing)
2
+ // while `me` loads, while anonymous, and when the check fails. UX gating
3
+ // only — the server stays authoritative on every request it receives.
4
+ import type { ReactNode } from 'react';
5
+ import { useCan } from '../client/me';
6
+
7
+ export function Guarded({ permission, fallback = null, children }: {
8
+ /** Permission name(s); an array means ANY-of. `admin` and superuser always pass. */
9
+ permission: string | string[];
10
+ /** Rendered when the check fails (default: nothing). */
11
+ fallback?: ReactNode;
12
+ children: ReactNode;
13
+ }) {
14
+ const { can } = useCan(permission);
15
+ return <>{can ? children : fallback}</>;
16
+ }
@@ -0,0 +1,89 @@
1
+ // MarkdownView — render markdown the platform way: django-mojo's docit
2
+ // renderer does the work, an allowlist sanitizer vets the answer, and a
3
+ // dependency-free client renderer covers everything else.
4
+ //
5
+ // The one sanctioned `dangerouslySetInnerHTML` in portal-mojo lives here, and
6
+ // the ONLY string it can ever receive is the output of `renderMarkdown`, which
7
+ // sanitizes before it resolves (client/markdown-sanitize.ts spells out the
8
+ // trust model). Everything else on screen is React elements.
9
+ //
10
+ // No loading jump, by construction:
11
+ // 1. Already rendered this exact source? The cache answers synchronously and
12
+ // the server HTML is on the FIRST frame — no request, no transition.
13
+ // 2. Otherwise the client fallback paints immediately (real content at
14
+ // roughly the right height, marked `aria-busy`), and the server HTML
15
+ // swaps in underneath the same wrapper when it lands.
16
+ // 3. If the render fails, the fallback simply stays — with one console.warn
17
+ // saying why. There is no empty state and no spinner at any point.
18
+ import { useEffect, useState } from 'react';
19
+ import { MarkdownFallback, peekMarkdown, renderMarkdown } from '../client/markdown';
20
+
21
+ export interface MarkdownViewProps {
22
+ /** The markdown source. */
23
+ source: string;
24
+ /**
25
+ * 'server' (default) asks /api/docit/render and sanitizes the answer;
26
+ * 'client' never touches the wire — for previews of text being typed, and
27
+ * for surfaces that must not fan out one request per item.
28
+ */
29
+ renderer?: 'server' | 'client';
30
+ /**
31
+ * Wait this long after the source stops changing before calling the server.
32
+ * Leave at 0 for static content; set it for text that grows token by token
33
+ * (a streaming assistant reply) so one reply is one request, not hundreds.
34
+ */
35
+ debounceMs?: number;
36
+ /** Extra classes on the wrapper; `md` is always present. */
37
+ className?: string;
38
+ }
39
+
40
+ export function MarkdownView({ source, renderer = 'server', debounceMs = 0, className }: MarkdownViewProps) {
41
+ const [served, setServed] = useState<{ source: string; html: string } | null>(null);
42
+ const [failedFor, setFailedFor] = useState<string | null>(null);
43
+
44
+ // Derived, never mirrored into state: a cache hit must win on the first
45
+ // frame, and it must re-evaluate when `source` changes. In client mode it
46
+ // is always null — flipping to 'client' must not keep showing server HTML
47
+ // that a previous 'server' pass had already resolved.
48
+ const html =
49
+ renderer === 'server'
50
+ ? peekMarkdown(source) ?? (served?.source === source ? served.html : null)
51
+ : null;
52
+
53
+ useEffect(() => {
54
+ if (renderer !== 'server' || !source.trim()) return;
55
+ if (peekMarkdown(source) !== null) return;
56
+
57
+ let cancelled = false;
58
+ const run = () => {
59
+ renderMarkdown(source).then(
60
+ (out) => { if (!cancelled) setServed({ source, html: out }); },
61
+ (err: unknown) => {
62
+ if (cancelled) return;
63
+ // The fallback is already on screen — this only records why
64
+ // it is what the reader is looking at.
65
+ console.warn('[MarkdownView] server render failed, keeping the client fallback:', err);
66
+ setFailedFor(source);
67
+ },
68
+ );
69
+ };
70
+
71
+ if (debounceMs > 0) {
72
+ const timer = setTimeout(run, debounceMs);
73
+ return () => { cancelled = true; clearTimeout(timer); };
74
+ }
75
+ run();
76
+ return () => { cancelled = true; };
77
+ }, [source, renderer, debounceMs]);
78
+
79
+ const pending = renderer === 'server' && !!source.trim() && html === null && failedFor !== source;
80
+ const cls = ['md', pending ? 'md-pending' : null, className].filter(Boolean).join(' ');
81
+
82
+ // Sanitized at its source (renderMarkdown) — see the trust model above.
83
+ if (html !== null) return <div className={cls} dangerouslySetInnerHTML={{ __html: html }} />;
84
+ return (
85
+ <div className={cls} aria-busy={pending || undefined}>
86
+ <MarkdownFallback source={source} />
87
+ </div>
88
+ );
89
+ }