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,369 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { FieldValue } from '../client/types';
3
+ import { mojoCall } from '../client/client';
4
+ import type { StartFileUploadOptions, UploadedFileRef } from '../client/upload';
5
+ import { FileDropZone, type FileSelectionResult } from './FileDrop';
6
+ import { useUploadQueue } from './UploadQueue';
7
+ import { fileRelationId } from './field-wire';
8
+ import { toast } from './toast';
9
+ import { imageEditorModal, type ImageEditorModalOptions } from './image-editor';
10
+
11
+ export type FileFieldState = 'keep' | 'clear' | 'replacement-in-progress' | 'replacement-failed'
12
+ | 'completed-awaiting-attach' | 'attach-failed' | 'edit-pending' | 'edit-ready' | 'edit-failed';
13
+
14
+ export interface FileFieldOwnerResult {
15
+ generation: number;
16
+ status: 'success' | 'failed';
17
+ /** Exact relation value sent by this owner-save attempt. */
18
+ requestedValue: unknown;
19
+ /** Raw relation value from the authoritative owner response. */
20
+ authoritativeValue?: unknown;
21
+ }
22
+
23
+ export interface FileFieldProps {
24
+ value: FieldValue;
25
+ onChange: (value: number | null) => void;
26
+ image?: boolean;
27
+ accept?: string | string[];
28
+ maxFileSize?: number;
29
+ destination?: StartFileUploadOptions;
30
+ disabled?: boolean;
31
+ invalid?: boolean;
32
+ controlId?: string;
33
+ ariaDescribedBy?: string;
34
+ onPendingChange?: (pending: boolean) => void;
35
+ ownerResult?: FileFieldOwnerResult;
36
+ onOrphan?: (fileId: number) => void;
37
+ /** Image-only, opt-in editor that runs before any File upload is initiated. */
38
+ edit?: boolean | ImageEditorModalOptions;
39
+ /** Require an editor result; Cancel retains the selection for retry/discard only. */
40
+ requireEdit?: boolean;
41
+ }
42
+
43
+ export function reconcileFileOwnerResult(expected: number | null, result: FileFieldOwnerResult, lastGeneration: number): 'ignore' | 'attached' | 'failed' {
44
+ if (result.generation <= lastGeneration) return 'ignore';
45
+ const requested = fileRelationId(result.requestedValue);
46
+ const requestedMatches = expected == null ? result.requestedValue === null : requested === expected;
47
+ if (!requestedMatches) return 'ignore';
48
+ if (result.status === 'failed') return 'failed';
49
+ const authoritative = fileRelationId(result.authoritativeValue);
50
+ const authoritativeMatches = expected == null ? result.authoritativeValue === null : authoritative === expected;
51
+ return authoritativeMatches ? 'attached' : 'failed';
52
+ }
53
+
54
+ interface PreviewRow { url?: unknown; thumbnail?: unknown; filename?: unknown }
55
+
56
+ function safePreviewUrl(value: unknown): string | null {
57
+ if (typeof value !== 'string' || value.trim() !== value || !value || value.startsWith('//')) return null;
58
+ if (value.startsWith('/')) return /[\r\n\\]/.test(value) ? null : value;
59
+ try {
60
+ const url = new URL(value);
61
+ return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password
62
+ ? value : null;
63
+ } catch { return null; }
64
+ }
65
+
66
+ /**
67
+ * Controlled django-mojo File relation editor. Browser File values, object
68
+ * URLs, upload tasks, and stored preview capabilities stay component-local.
69
+ */
70
+ export function FileField(props: FileFieldProps) {
71
+ const {
72
+ image = false, disabled = false, invalid = false, destination,
73
+ onPendingChange, ownerResult, onOrphan,
74
+ } = props;
75
+ const initialId = fileRelationId(props.value);
76
+ const storedId = useRef<number | null>(initialId);
77
+ const desired = useRef<number | null | undefined>(undefined);
78
+ const candidate = useRef<number | null>(null);
79
+ const candidateFilename = useRef<string | null>(null);
80
+ const selectedOriginal = useRef<File | null>(null);
81
+ const editController = useRef<AbortController | null>(null);
82
+ const generation = useRef(0);
83
+ const ownerGeneration = useRef(0);
84
+ const orphaned = useRef(new Set<number>());
85
+ const mounted = useRef(true);
86
+ const localUrl = useRef<string | null>(null);
87
+ const latestOrphan = useRef(onOrphan);
88
+ latestOrphan.current = onOrphan;
89
+ const [preview, setPreview] = useState<string | null>(null);
90
+ const [filename, setFilename] = useState<string | null>(null);
91
+ const [state, setState] = useState<FileFieldState>('keep');
92
+ const [editing, setEditing] = useState(false);
93
+ const queue = useUploadQueue({ concurrency: 1, capacity: 1 });
94
+
95
+ const revokeLocal = useCallback(() => {
96
+ if (localUrl.current) URL.revokeObjectURL(localUrl.current);
97
+ localUrl.current = null;
98
+ }, []);
99
+ const reportOrphan = useCallback((id: number | null) => {
100
+ if (id == null || orphaned.current.has(id)) return;
101
+ orphaned.current.add(id);
102
+ latestOrphan.current?.(id);
103
+ }, []);
104
+ const clearQueue = useCallback(() => {
105
+ for (const item of queue.snapshot.items) {
106
+ if (item.canCancel) queue.cancel(item.id);
107
+ queue.remove(item.id);
108
+ }
109
+ }, [queue]);
110
+
111
+ useEffect(() => () => {
112
+ mounted.current = false;
113
+ generation.current += 1;
114
+ editController.current?.abort();
115
+ revokeLocal();
116
+ if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
117
+ queue.cancelAll();
118
+ }, [queue, reportOrphan, revokeLocal]);
119
+
120
+ const active = queue.snapshot.activeCount > 0 || queue.snapshot.queuedCount > 0;
121
+ const pending = active || editing;
122
+ useEffect(() => {
123
+ if (!disabled) return;
124
+ if (active) queue.cancelAll();
125
+ if (editController.current) {
126
+ editController.current.abort();
127
+ setEditing(false);
128
+ setState('edit-ready');
129
+ }
130
+ }, [active, disabled, queue]);
131
+
132
+ useEffect(() => {
133
+ onPendingChange?.(pending);
134
+ return () => onPendingChange?.(false);
135
+ }, [onPendingChange, pending]);
136
+
137
+ // Stored capabilities are fetched only while mounted, never returned to a
138
+ // caller or written through Query. Failures intentionally reveal nothing.
139
+ useEffect(() => {
140
+ if (!image || storedId.current == null || localUrl.current) {
141
+ if (storedId.current == null && !localUrl.current) setPreview(null);
142
+ return;
143
+ }
144
+ const controller = new AbortController();
145
+ let live = true;
146
+ void mojoCall(`/api/fileman/file/${storedId.current}`, { signal: controller.signal }).then((envelope) => {
147
+ const row = envelope.data as PreviewRow;
148
+ if (!live || controller.signal.aborted || localUrl.current) return;
149
+ setPreview(safePreviewUrl(row.thumbnail) ?? safePreviewUrl(row.url));
150
+ setFilename(typeof row.filename === 'string' ? row.filename : null);
151
+ }, () => { /* capability and failure remain local and silent */ });
152
+ return () => { live = false; controller.abort(); };
153
+ }, [image, initialId, state]);
154
+
155
+ useEffect(() => {
156
+ if (!ownerResult || desired.current === undefined) return;
157
+ const outcome = reconcileFileOwnerResult(desired.current, ownerResult, ownerGeneration.current);
158
+ if (outcome === 'ignore') return;
159
+ ownerGeneration.current = ownerResult.generation;
160
+ const expected = desired.current;
161
+ if (outcome === 'failed') {
162
+ setState('attach-failed');
163
+ return;
164
+ }
165
+ storedId.current = expected;
166
+ desired.current = undefined;
167
+ candidate.current = null;
168
+ candidateFilename.current = null;
169
+ setFilename(null);
170
+ revokeLocal();
171
+ setPreview(null);
172
+ setState('keep');
173
+ queue.clearSettled();
174
+ }, [ownerResult, queue, revokeLocal]);
175
+
176
+ // A genuinely external authoritative row change can refresh the stored
177
+ // baseline, but a local draft never replaces it while attachment awaits.
178
+ useEffect(() => {
179
+ if (desired.current !== undefined) return;
180
+ storedId.current = initialId;
181
+ }, [initialId]);
182
+
183
+ useEffect(() => {
184
+ const item = queue.snapshot.items[0];
185
+ if (!item) {
186
+ if (state === 'replacement-in-progress') setState('replacement-failed');
187
+ return;
188
+ }
189
+ if (active) {
190
+ if (state === 'replacement-failed') setState('replacement-in-progress');
191
+ return;
192
+ }
193
+ if (item.status === 'failed' || item.status === 'uncertain' || item.status === 'cancelled') {
194
+ setState('replacement-failed');
195
+ }
196
+ }, [active, queue.snapshot.items, state]);
197
+
198
+ const enqueueFile = (file: File, acceptedGeneration: number) => {
199
+ selectedOriginal.current = null;
200
+ reportOrphan(candidate.current);
201
+ candidate.current = null;
202
+ desired.current = undefined;
203
+ clearQueue();
204
+ revokeLocal();
205
+ localUrl.current = URL.createObjectURL(file);
206
+ setPreview(image ? localUrl.current : null);
207
+ setFilename(file.name);
208
+ setState('replacement-in-progress');
209
+ const result = queue.enqueue([file], {
210
+ consumerKey: `form-file:${props.controlId ?? 'field'}`,
211
+ destination,
212
+ onComplete: async (uploaded: UploadedFileRef) => {
213
+ if (!mounted.current || generation.current !== acceptedGeneration) return;
214
+ const id = fileRelationId(uploaded.id);
215
+ if (id == null) throw new Error('Upload completion did not include an authoritative File id');
216
+ candidate.current = id;
217
+ candidateFilename.current = uploaded.filename;
218
+ desired.current = id;
219
+ setState('completed-awaiting-attach');
220
+ props.onChange(id);
221
+ },
222
+ });
223
+ result.rejected.forEach((item) => toast.warning(item.message));
224
+ if (!result.acceptedIds.length) setState('replacement-failed');
225
+ };
226
+
227
+ const editSelected = async (file: File, acceptedGeneration: number) => {
228
+ editController.current?.abort();
229
+ const controller = new AbortController();
230
+ editController.current = controller;
231
+ setEditing(true);
232
+ setState('edit-pending');
233
+ try {
234
+ const configured: ImageEditorModalOptions = typeof props.edit === 'object' ? props.edit : {};
235
+ const result = await imageEditorModal(file, {
236
+ ...configured,
237
+ filename: configured.filename ?? file.name,
238
+ signal: controller.signal,
239
+ });
240
+ if (!mounted.current || controller.signal.aborted || generation.current !== acceptedGeneration) return;
241
+ if (!result) {
242
+ setState('edit-ready');
243
+ return;
244
+ }
245
+ const edited = new File([result.blob], result.filename, { type: 'image/png', lastModified: file.lastModified });
246
+ enqueueFile(edited, acceptedGeneration);
247
+ } catch {
248
+ if (mounted.current && !controller.signal.aborted && generation.current === acceptedGeneration) setState('edit-failed');
249
+ } finally {
250
+ if (mounted.current && editController.current === controller) {
251
+ editController.current = null;
252
+ setEditing(false);
253
+ }
254
+ }
255
+ };
256
+
257
+ const select = (selection: FileSelectionResult) => {
258
+ selection.rejected.forEach((item) => toast.warning(item.message));
259
+ const file = selection.accepted[0];
260
+ if (!file) return;
261
+ generation.current += 1;
262
+ const acceptedGeneration = generation.current;
263
+ editController.current?.abort();
264
+ reportOrphan(candidate.current);
265
+ candidate.current = null;
266
+ desired.current = undefined;
267
+ clearQueue();
268
+ revokeLocal();
269
+ localUrl.current = URL.createObjectURL(file);
270
+ setPreview(image ? localUrl.current : null);
271
+ setFilename(file.name);
272
+ if (image && props.edit) {
273
+ selectedOriginal.current = file;
274
+ setState('edit-ready');
275
+ void editSelected(file, acceptedGeneration);
276
+ } else {
277
+ enqueueFile(file, acceptedGeneration);
278
+ }
279
+ };
280
+
281
+ const clear = () => {
282
+ generation.current += 1;
283
+ editController.current?.abort();
284
+ selectedOriginal.current = null;
285
+ reportOrphan(candidate.current);
286
+ candidate.current = null;
287
+ desired.current = null;
288
+ clearQueue();
289
+ revokeLocal();
290
+ setState('clear');
291
+ props.onChange(null);
292
+ };
293
+ const removeCandidate = () => {
294
+ generation.current += 1;
295
+ editController.current?.abort();
296
+ selectedOriginal.current = null;
297
+ reportOrphan(candidate.current);
298
+ candidate.current = null;
299
+ desired.current = undefined;
300
+ clearQueue();
301
+ revokeLocal();
302
+ setFilename(null);
303
+ setPreview(null);
304
+ setState('keep');
305
+ props.onChange(storedId.current);
306
+ };
307
+ const retryAttach = () => {
308
+ if (desired.current === undefined) return;
309
+ setState(desired.current == null ? 'clear' : 'completed-awaiting-attach');
310
+ props.onChange(desired.current);
311
+ };
312
+ const retryEdit = () => {
313
+ const file = selectedOriginal.current;
314
+ if (file) void editSelected(file, generation.current);
315
+ };
316
+ const useOriginal = () => {
317
+ const file = selectedOriginal.current;
318
+ if (file) enqueueFile(file, generation.current);
319
+ };
320
+ const discardSelection = () => {
321
+ generation.current += 1;
322
+ editController.current?.abort();
323
+ selectedOriginal.current = null;
324
+ revokeLocal();
325
+ setFilename(null);
326
+ setPreview(null);
327
+ setState('keep');
328
+ };
329
+
330
+ const item = queue.snapshot.items[0];
331
+ const hasStored = storedId.current != null;
332
+ const statusText = state === 'keep' ? (hasStored ? 'Keep current file' : 'No file selected')
333
+ : state === 'clear' ? 'Clear on save'
334
+ : state === 'replacement-in-progress' ? (item?.percent == null ? 'Uploading…' : `Uploading… ${item.percent}%`)
335
+ : state === 'replacement-failed' ? (item?.failure?.message ?? 'Upload failed')
336
+ : state === 'completed-awaiting-attach' ? 'Uploaded · awaiting save'
337
+ : state === 'attach-failed' ? 'Uploaded · attachment failed'
338
+ : state === 'edit-pending' ? 'Editing before upload…'
339
+ : state === 'edit-failed' ? 'Editing failed · original selection preserved'
340
+ : 'Selected · not uploaded';
341
+
342
+ return (
343
+ <div id={props.controlId} className={`file-field${image ? ' image-field' : ''}${invalid ? ' is-invalid' : ''}`} aria-describedby={props.ariaDescribedBy}>
344
+ {image && <div className="file-field-preview">{preview ? <img src={preview} alt="Selected preview" referrerPolicy="no-referrer" /> : <i className="bi bi-image" aria-hidden="true" />}</div>}
345
+ <div className="file-field-main">
346
+ <strong>{filename ?? candidateFilename.current ?? (hasStored ? `File #${storedId.current}` : 'No file')}</strong>
347
+ <span className={`file-field-status is-${state}`}>{statusText}</span>
348
+ {item?.canRetry && <button type="button" className="btn btn-compact" disabled={disabled} onClick={() => queue.retry(item.id)}>Retry upload</button>}
349
+ {state === 'attach-failed' && <button type="button" className="btn btn-compact" disabled={disabled} onClick={retryAttach}>Retry attachment</button>}
350
+ {(state === 'edit-ready' || state === 'edit-failed') && <div className="file-field-edit-actions">
351
+ <button type="button" className="btn btn-compact" disabled={disabled} onClick={retryEdit}>{state === 'edit-failed' ? 'Retry edit' : 'Edit'}</button>
352
+ {!props.requireEdit && <button type="button" className="btn btn-compact" disabled={disabled} onClick={useOriginal}>Use original</button>}
353
+ <button type="button" className="btn btn-compact" disabled={disabled} onClick={discardSelection}>Discard</button>
354
+ </div>}
355
+ </div>
356
+ <div className="file-field-actions">
357
+ <FileDropZone className="file-field-drop" label={hasStored || candidate.current || selectedOriginal.current ? 'Choose replacement file or drop it here' : 'Choose a file or drop it here'} multiple={false} accept={image ? props.accept ?? 'image/*' : props.accept} maxFileSize={props.maxFileSize} disabled={disabled || pending} onSelection={select}>
358
+ <span><i className="bi bi-cloud-arrow-up" /> {hasStored || candidate.current || selectedOriginal.current ? 'Replace' : 'Choose file'}</span>
359
+ </FileDropZone>
360
+ {(hasStored || desired.current !== undefined) && <button type="button" className="btn btn-compact" disabled={disabled || active} onClick={desired.current !== undefined ? removeCandidate : clear}>{desired.current !== undefined ? 'Remove' : 'Clear'}</button>}
361
+ {item?.canCancel && <button type="button" className="btn btn-compact" disabled={disabled} onClick={() => queue.cancel(item.id)}>Cancel</button>}
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ export function ImageField(props: Omit<FileFieldProps, 'image'>) {
368
+ return <FileField {...props} image />;
369
+ }
@@ -0,0 +1,269 @@
1
+ // FilterBar — web-mojo's filter UX: an "Add filter" menu listing every
2
+ // available filter with a per-type icon and a check on the active ones, each
3
+ // opening a type-appropriate dialog; applied filters become editable pills.
4
+ // Adding a filter to a page is one entry in a `filters` array.
5
+ import { useEffect, useRef, useState } from 'react';
6
+ import { modal } from './modal';
7
+ import { DateRangePicker } from './date/DateRangePicker';
8
+ import { formatFilterDisplay } from '../client/lookups';
9
+ import type { ActiveFilter, TableParamsApi } from '../client/params';
10
+
11
+ export type FilterType = 'text' | 'select' | 'multiselect' | 'number' | 'boolean' | 'date' | 'daterange';
12
+
13
+ export interface FilterDef {
14
+ key: string; // model field
15
+ label: string;
16
+ type: FilterType;
17
+ options?: { value: string; label: string }[];
18
+ /** Django lookup override; defaults per type (text→icontains, number→gte). */
19
+ lookup?: string;
20
+ trueLabel?: string;
21
+ falseLabel?: string;
22
+ placeholder?: string;
23
+ }
24
+
25
+ const ICONS: Record<FilterType, string> = {
26
+ text: 'bi-search',
27
+ select: 'bi-funnel',
28
+ multiselect: 'bi-funnel',
29
+ number: 'bi-123',
30
+ boolean: 'bi-toggle-on',
31
+ date: 'bi-calendar',
32
+ daterange: 'bi-calendar-range',
33
+ };
34
+
35
+ /** The wire key a filter writes — daterange uses the dr_* triple instead. */
36
+ export function paramKeyFor(def: FilterDef): string {
37
+ switch (def.type) {
38
+ case 'text': return `${def.key}__${def.lookup ?? 'icontains'}`;
39
+ case 'multiselect': return `${def.key}__in`;
40
+ case 'number': return `${def.key}__${def.lookup ?? 'gte'}`;
41
+ case 'date': return `${def.key}__${def.lookup ?? 'gte'}`;
42
+ default: return def.key;
43
+ }
44
+ }
45
+
46
+ function defForActive(defs: FilterDef[], f: ActiveFilter): FilterDef | undefined {
47
+ if (f.kind === 'daterange') return defs.find((d) => d.type === 'daterange' && d.key === f.id);
48
+ return defs.find((d) => {
49
+ if (d.type === 'daterange') return false;
50
+ return paramKeyFor(d) === f.id || d.key === f.id || `${d.key}__in` === f.id;
51
+ });
52
+ }
53
+
54
+ function isActive(defs: FilterDef[], active: ActiveFilter[], def: FilterDef): boolean {
55
+ return active.some((f) => defForActive(defs, f) === def);
56
+ }
57
+
58
+ function pillText(def: FilterDef | undefined, f: ActiveFilter): string {
59
+ if (f.kind === 'daterange') {
60
+ const label = def?.label ?? f.id;
61
+ if (f.start && f.end) return `${label}: ${f.start} → ${f.end}`;
62
+ if (f.start) return `${label}: from ${f.start}`;
63
+ return `${label}: until ${f.end}`;
64
+ }
65
+ const raw = f.value ?? '';
66
+ // Map stored values back to their option labels where we have them.
67
+ const pretty = def?.options
68
+ ? raw.split(',').map((v) => def.options!.find((o) => o.value === v.trim())?.label ?? v).join(', ')
69
+ : def?.type === 'boolean'
70
+ ? (raw === 'true' ? (def.trueLabel ?? 'True') : (def.falseLabel ?? 'False'))
71
+ : raw;
72
+ return formatFilterDisplay(f.id, pretty, def?.label);
73
+ }
74
+
75
+ // ── per-type dialogs ──────────────────────────────────────────────────
76
+ type DialogResult = { clear: true } | { value: string | string[] } | { start: string; end: string } | null;
77
+
78
+ function FilterForm({ def, current, onDone }: {
79
+ def: FilterDef;
80
+ current: ActiveFilter | undefined;
81
+ onDone: (r: DialogResult) => void;
82
+ }) {
83
+ const [text, setText] = useState(current?.value ?? '');
84
+ const [multi, setMulti] = useState<string[]>(
85
+ current?.value ? current.value.split(',').map((s) => s.trim()) : [],
86
+ );
87
+ const [start, setStart] = useState(current?.start ?? '');
88
+ const [end, setEnd] = useState(current?.end ?? '');
89
+
90
+ const submit = (e: React.FormEvent) => {
91
+ e.preventDefault();
92
+ if (def.type === 'daterange') onDone({ start, end });
93
+ else if (def.type === 'multiselect') onDone({ value: multi });
94
+ else onDone({ value: text });
95
+ };
96
+
97
+ return (
98
+ <form className="modal-pad" onSubmit={submit}>
99
+ <h2 className="modal-title">{def.label}</h2>
100
+ {def.type === 'daterange' ? (
101
+ /* Inline DateRangePicker (preset rail on) replaced the two
102
+ bare native date inputs. Same staged start/end state, same
103
+ Apply/Remove/Cancel semantics and dr_* wire values — one
104
+ calendar pane so the body fits the md dialog. */
105
+ <DateRangePicker
106
+ inline
107
+ months={1}
108
+ presets="default"
109
+ start={start}
110
+ end={end}
111
+ onChange={(e) => { setStart(e.start); setEnd(e.end); }}
112
+ />
113
+ ) : def.type === 'multiselect' ? (
114
+ <div className="check-list">
115
+ {(def.options ?? []).map((o) => (
116
+ <label key={o.value} className="check-row">
117
+ <input
118
+ type="checkbox"
119
+ checked={multi.includes(o.value)}
120
+ onChange={(e) => setMulti((prev) => e.target.checked
121
+ ? [...prev, o.value]
122
+ : prev.filter((v) => v !== o.value))}
123
+ />
124
+ {o.label}
125
+ </label>
126
+ ))}
127
+ </div>
128
+ ) : def.type === 'select' || def.type === 'boolean' ? (
129
+ <select className="input" value={text} onChange={(e) => setText(e.target.value)} autoFocus>
130
+ <option value="">Select…</option>
131
+ {(def.type === 'boolean'
132
+ ? [{ value: 'true', label: def.trueLabel ?? 'True' }, { value: 'false', label: def.falseLabel ?? 'False' }]
133
+ : def.options ?? []
134
+ ).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
135
+ </select>
136
+ ) : (
137
+ <input
138
+ className="input"
139
+ type={def.type === 'number' ? 'number' : def.type === 'date' ? 'date' : 'text'}
140
+ value={text}
141
+ placeholder={def.placeholder}
142
+ onChange={(e) => setText(e.target.value)}
143
+ autoFocus
144
+ />
145
+ )}
146
+ {def.type === 'number' && (
147
+ <span className="field-help">Matches records where {def.label.toLowerCase()} is ≥ this value.</span>
148
+ )}
149
+ <div className="modal-actions">
150
+ {current && <button type="button" className="btn" onClick={() => onDone({ clear: true })}>Remove</button>}
151
+ <button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button>
152
+ <button type="submit" className="btn btn-primary">Apply</button>
153
+ </div>
154
+ </form>
155
+ );
156
+ }
157
+
158
+ async function openFilterDialog(def: FilterDef, current: ActiveFilter | undefined, p: TableParamsApi) {
159
+ const result = await modal.open<DialogResult>((close) => (
160
+ <FilterForm def={def} current={current} onDone={(r) => close(r)} />
161
+ ), { size: def.type === 'daterange' ? 'md' : 'sm' });
162
+ if (!result) return;
163
+ if (def.type === 'daterange') {
164
+ if ('clear' in result) p.setDateRange(def.key, '', '');
165
+ else if ('start' in result) p.setDateRange(def.key, result.start, result.end);
166
+ return;
167
+ }
168
+ if ('clear' in result) p.setFilter(paramKeyFor(def), null);
169
+ else if ('value' in result) p.setFilter(paramKeyFor(def), result.value);
170
+ }
171
+
172
+ // ── the bar ───────────────────────────────────────────────────────────
173
+ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
174
+ const [open, setOpen] = useState(false);
175
+ const wrapRef = useRef<HTMLDivElement>(null);
176
+
177
+ useEffect(() => {
178
+ if (!open) return;
179
+ const onDown = (e: MouseEvent) => {
180
+ if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
181
+ };
182
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
183
+ document.addEventListener('mousedown', onDown);
184
+ document.addEventListener('keydown', onKey);
185
+ return () => {
186
+ document.removeEventListener('mousedown', onDown);
187
+ document.removeEventListener('keydown', onKey);
188
+ };
189
+ }, [open]);
190
+
191
+ const active = params.activeFilters;
192
+
193
+ return (
194
+ <div className="filter-menu-wrap" ref={wrapRef}>
195
+ <button
196
+ className={`btn btn-compact${active.length ? ' btn-has-filters' : ''}`}
197
+ onClick={() => setOpen((v) => !v)}
198
+ aria-expanded={open}
199
+ title="Filter"
200
+ >
201
+ {/* Label collapses on normal widths (tool-group icon mode);
202
+ the active-count badge stays visible either way. */}
203
+ <i className="bi bi-funnel" /> <span className="btn-label">Filter</span>
204
+ {active.length > 0 && <span className="filter-count">{active.length}</span>}
205
+ </button>
206
+ {open && (
207
+ <div className="filter-menu" role="menu">
208
+ {defs.map((def) => {
209
+ const on = isActive(defs, active, def);
210
+ return (
211
+ <button
212
+ key={`${paramKeyFor(def)}:${def.label}`}
213
+ className={`filter-menu-item${on ? ' filter-on' : ''}`}
214
+ onClick={() => {
215
+ setOpen(false);
216
+ const current = active.find((f) => defForActive(defs, f) === def);
217
+ void openFilterDialog(def, current, params);
218
+ }}
219
+ >
220
+ <i className={`bi ${ICONS[def.type]}`} />
221
+ <span>{def.label}</span>
222
+ {on && <i className="bi bi-check2 filter-check" />}
223
+ </button>
224
+ );
225
+ })}
226
+ {active.length > 0 && (
227
+ <>
228
+ <div className="filter-menu-sep" />
229
+ <button
230
+ className="filter-menu-item filter-menu-clear"
231
+ onClick={() => { setOpen(false); params.clearFilters(); }}
232
+ >
233
+ <i className="bi bi-x-circle" /> Clear all filters
234
+ </button>
235
+ </>
236
+ )}
237
+ </div>
238
+ )}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ /** Active-filter pills — click to edit, × to remove. */
244
+ export function FilterPills({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
245
+ const active = params.activeFilters;
246
+ if (active.length === 0) return null;
247
+ return (
248
+ <div className="pill-row">
249
+ {active.map((f) => {
250
+ const def = defForActive(defs, f);
251
+ return (
252
+ <span key={`${f.kind}:${f.id}`} className="pill">
253
+ <button
254
+ className="pill-edit"
255
+ title="Edit filter"
256
+ onClick={() => def && void openFilterDialog(def, f, params)}
257
+ >
258
+ {pillText(def, f)}
259
+ </button>
260
+ <button className="pill-x" title="Remove filter" onClick={() => params.removeFilter(f)}>
261
+ <i className="bi bi-x" />
262
+ </button>
263
+ </span>
264
+ );
265
+ })}
266
+ <button className="pill-clear" onClick={() => params.clearFilters()}>Clear all</button>
267
+ </div>
268
+ );
269
+ }