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,603 @@
1
+ import {
2
+ useCallback, useEffect, useId, useRef, useSyncExternalStore,
3
+ type ReactNode,
4
+ } from 'react';
5
+ import {
6
+ sanitizeUploadBasename, startFileUpload,
7
+ type FileUploadFailure, type FileUploadSnapshot, type FileUploadTask,
8
+ type StartFileUploadOptions, type UploadedFileRef,
9
+ } from '../client/upload';
10
+ import { getAuthSnapshot, subscribeAuth } from '../client/auth';
11
+ import { filesize } from './format';
12
+ import { toast, type ProgressToastHandle } from './toast';
13
+
14
+ export type UploadQueueItemStatus = 'queued' | 'initiating' | 'uploading' | 'reconciling'
15
+ | 'completing' | 'cancelling' | 'finalizing' | 'completed' | 'completed-warning'
16
+ | 'failed' | 'cancelled' | 'uncertain';
17
+
18
+ export interface UploadQueueFailure {
19
+ stage: FileUploadFailure['stage'] | 'callback';
20
+ code: FileUploadFailure['code'] | 'callback_failed';
21
+ message: string;
22
+ retryable: boolean;
23
+ }
24
+
25
+ export interface UploadQueueItem {
26
+ id: string;
27
+ filename: string;
28
+ contentType: string;
29
+ size: number;
30
+ status: UploadQueueItemStatus;
31
+ loadedBytes: number;
32
+ totalBytes: number | null;
33
+ percent: number | null;
34
+ file: UploadedFileRef | null;
35
+ failure: UploadQueueFailure | null;
36
+ canCancel: boolean;
37
+ canRetry: boolean;
38
+ canRecover: boolean;
39
+ canRemove: boolean;
40
+ }
41
+
42
+ export interface UploadQueueSnapshot {
43
+ items: readonly UploadQueueItem[];
44
+ activeCount: number;
45
+ queuedCount: number;
46
+ completedCount: number;
47
+ warningCount: number;
48
+ failedCount: number;
49
+ loadedBytes: number;
50
+ totalBytes: number | null;
51
+ percent: number | null;
52
+ }
53
+
54
+ export interface UploadQueueCompletionContext {
55
+ queueItemId: string;
56
+ signal: AbortSignal;
57
+ }
58
+
59
+ export type UploadQueueCompletion = (
60
+ file: UploadedFileRef,
61
+ context: UploadQueueCompletionContext,
62
+ ) => void | Promise<void>;
63
+
64
+ export interface UploadQueueEnqueueOptions {
65
+ /** A stable consumer identity, used with destination and file metadata for dedupe. */
66
+ consumerKey: string;
67
+ destination?: StartFileUploadOptions;
68
+ onComplete?: UploadQueueCompletion;
69
+ }
70
+
71
+ export interface UploadQueueRejection {
72
+ filename: string;
73
+ code: 'capacity' | 'duplicate';
74
+ message: string;
75
+ }
76
+
77
+ export interface UploadQueueEnqueueResult {
78
+ acceptedIds: string[];
79
+ rejected: UploadQueueRejection[];
80
+ }
81
+
82
+ export interface UploadQueueStore {
83
+ getSnapshot(): UploadQueueSnapshot;
84
+ subscribe(listener: () => void): () => void;
85
+ enqueue(files: Iterable<File>, options: UploadQueueEnqueueOptions): UploadQueueEnqueueResult;
86
+ cancel(id: string): void;
87
+ retry(id: string): void;
88
+ recover(id: string): void;
89
+ remove(id: string): void;
90
+ cancelAll(): void;
91
+ retryAll(): void;
92
+ clearSettled(): void;
93
+ mount(): () => void;
94
+ dispose(): void;
95
+ }
96
+
97
+ export interface UploadQueueOptions {
98
+ concurrency?: number;
99
+ capacity?: number;
100
+ /** Deterministic test/demo seam. Production callers should omit this. */
101
+ startTask?: (file: File, options: StartFileUploadOptions) => FileUploadTask;
102
+ }
103
+
104
+ interface PrivateEntry {
105
+ id: string;
106
+ filename: string;
107
+ contentType: string;
108
+ size: number;
109
+ dedupeKey: string;
110
+ destination: StartFileUploadOptions;
111
+ callbackController: AbortController | null;
112
+ unsubscribeTask: (() => void) | null;
113
+ status: UploadQueueItemStatus;
114
+ loadedBytes: number;
115
+ totalBytes: number | null;
116
+ fileRef: UploadedFileRef | null;
117
+ failure: UploadQueueFailure | null;
118
+ attempt: number;
119
+ lastToastLoaded: number;
120
+ lastToastPercent: number;
121
+ cancelRequested: boolean;
122
+ removeWhenSettled: boolean;
123
+ }
124
+
125
+ type PendingOperation = { id: string; mode: 'start' | 'retry' | 'recover' };
126
+
127
+ const EMPTY_SNAPSHOT: UploadQueueSnapshot = Object.freeze({
128
+ items: Object.freeze([]), activeCount: 0, queuedCount: 0, completedCount: 0,
129
+ warningCount: 0, failedCount: 0, loadedBytes: 0, totalBytes: null, percent: null,
130
+ });
131
+
132
+ const ACTIVE = new Set<UploadQueueItemStatus>([
133
+ 'initiating', 'uploading', 'reconciling', 'completing', 'cancelling', 'finalizing',
134
+ ]);
135
+ const SETTLED = new Set<UploadQueueItemStatus>([
136
+ 'completed', 'completed-warning', 'failed', 'cancelled', 'uncertain',
137
+ ]);
138
+
139
+ const cleanInt = (value: number | undefined): number | undefined =>
140
+ Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : undefined;
141
+
142
+ function normalizeDestination(value: StartFileUploadOptions = {}): StartFileUploadOptions {
143
+ const use = value.use?.trim();
144
+ return {
145
+ ...(cleanInt(value.fileManagerId) == null ? {} : { fileManagerId: cleanInt(value.fileManagerId) }),
146
+ ...(cleanInt(value.groupId) == null ? {} : { groupId: cleanInt(value.groupId) }),
147
+ ...(use ? { use } : {}),
148
+ };
149
+ }
150
+
151
+ function destinationKey(value: StartFileUploadOptions): string {
152
+ return `${value.fileManagerId ?? ''}|${value.groupId ?? ''}|${value.use ?? ''}`;
153
+ }
154
+
155
+ function safeFailure(value: FileUploadFailure): UploadQueueFailure {
156
+ return { stage: value.stage, code: value.code, message: value.message, retryable: value.retryable };
157
+ }
158
+
159
+ function displayedPercent(loaded: number, total: number | null): number | null {
160
+ if (total == null || total <= 0) return null;
161
+ return Math.max(0, Math.min(100, Math.round((loaded / total) * 100)));
162
+ }
163
+
164
+ function itemSnapshot(entry: PrivateEntry, task: FileUploadTask | undefined): UploadQueueItem {
165
+ const retryCallback = entry.status === 'completed-warning';
166
+ return Object.freeze({
167
+ id: entry.id,
168
+ filename: entry.filename,
169
+ contentType: entry.contentType,
170
+ size: entry.size,
171
+ status: entry.status,
172
+ loadedBytes: entry.loadedBytes,
173
+ totalBytes: entry.totalBytes,
174
+ percent: displayedPercent(entry.loadedBytes, entry.totalBytes),
175
+ file: entry.fileRef ? Object.freeze({ ...entry.fileRef }) : null,
176
+ failure: entry.failure ? Object.freeze({ ...entry.failure }) : null,
177
+ canCancel: entry.status === 'queued' || (ACTIVE.has(entry.status) && entry.status !== 'cancelling' && entry.status !== 'finalizing'),
178
+ canRetry: retryCallback || ((entry.status === 'failed' || entry.status === 'uncertain') && Boolean(entry.failure?.retryable)),
179
+ canRecover: entry.status === 'uncertain' && task?.getSnapshot().fileId != null,
180
+ canRemove: entry.status === 'queued' || SETTLED.has(entry.status),
181
+ });
182
+ }
183
+
184
+ function buildSnapshot(entries: readonly PrivateEntry[], tasks: ReadonlyMap<string, FileUploadTask>): UploadQueueSnapshot {
185
+ const items = Object.freeze(entries.map((entry) => itemSnapshot(entry, tasks.get(entry.id))));
186
+ const aggregate = entries.filter((entry) => entry.status !== 'cancelled');
187
+ const known = aggregate.filter((entry) => entry.totalBytes != null && entry.totalBytes! > 0);
188
+ const loadedBytes = known.reduce((sum, entry) => sum + Math.min(entry.loadedBytes, entry.totalBytes!), 0);
189
+ const totalBytes = known.length ? known.reduce((sum, entry) => sum + entry.totalBytes!, 0) : null;
190
+ return Object.freeze({
191
+ items,
192
+ activeCount: entries.filter((entry) => ACTIVE.has(entry.status)).length,
193
+ queuedCount: entries.filter((entry) => entry.status === 'queued').length,
194
+ completedCount: entries.filter((entry) => entry.status === 'completed' || entry.status === 'completed-warning').length,
195
+ warningCount: entries.filter((entry) => entry.status === 'completed-warning' || entry.status === 'uncertain').length,
196
+ failedCount: entries.filter((entry) => entry.status === 'failed').length,
197
+ loadedBytes,
198
+ totalBytes,
199
+ percent: displayedPercent(loadedBytes, totalBytes),
200
+ });
201
+ }
202
+
203
+ export function createUploadQueue(options: UploadQueueOptions = {}): UploadQueueStore {
204
+ const capacity = Math.max(1, Math.min(100, Math.trunc(options.capacity ?? 6) || 6));
205
+ const concurrency = Math.max(1, Math.min(capacity, 12, Math.trunc(options.concurrency ?? 3) || 3));
206
+ const startTask = options.startTask ?? startFileUpload;
207
+ const entries: PrivateEntry[] = [];
208
+ // Browser-native/capability-bearing values stay in private maps. Public
209
+ // snapshots are rebuilt solely from the inert fields above.
210
+ const fileValues = new Map<string, File>();
211
+ const tasks = new Map<string, FileUploadTask>();
212
+ const callbacks = new Map<string, UploadQueueCompletion>();
213
+ const progressToasts = new Map<string, ProgressToastHandle>();
214
+ const listeners = new Set<() => void>();
215
+ const pending: PendingOperation[] = [];
216
+ let snapshot = EMPTY_SNAPSHOT;
217
+ let activeTasks = 0;
218
+ let nextId = 1;
219
+ let disposed = false;
220
+ let mountCount = 0;
221
+ let disposeGeneration = 0;
222
+ let authUid: string | null = null;
223
+ let unsubscribeAuth: (() => void) | null = null;
224
+
225
+ const publish = () => {
226
+ if (disposed) return;
227
+ snapshot = buildSnapshot(entries, tasks);
228
+ for (const listener of listeners) listener();
229
+ };
230
+ const find = (id: string) => entries.find((entry) => entry.id === id);
231
+ const removeEntry = (entry: PrivateEntry) => {
232
+ entry.unsubscribeTask?.();
233
+ entry.unsubscribeTask = null;
234
+ entry.callbackController?.abort();
235
+ entry.callbackController = null;
236
+ progressToasts.get(entry.id)?.remove();
237
+ fileValues.delete(entry.id);
238
+ tasks.delete(entry.id);
239
+ callbacks.delete(entry.id);
240
+ progressToasts.delete(entry.id);
241
+ const index = entries.indexOf(entry);
242
+ if (index >= 0) entries.splice(index, 1);
243
+ for (let i = pending.length - 1; i >= 0; i--) if (pending[i]!.id === entry.id) pending.splice(i, 1);
244
+ };
245
+ const ensureAuthGuard = () => {
246
+ if (unsubscribeAuth || disposed) return;
247
+ const auth = getAuthSnapshot();
248
+ authUid = auth.authenticated ? auth.uid : null;
249
+ if (authUid == null) return;
250
+ unsubscribeAuth = subscribeAuth(() => {
251
+ const next = getAuthSnapshot();
252
+ if (!next.authenticated || next.uid !== authUid) store.dispose();
253
+ });
254
+ };
255
+ const finishToast = (entry: PrivateEntry, kind: 'done' | 'fail', message: string) => {
256
+ const handle = progressToasts.get(entry.id);
257
+ progressToasts.delete(entry.id);
258
+ handle?.[kind](message);
259
+ };
260
+ const beginCallback = async (entry: PrivateEntry, attempt: number, useTransferToast: boolean) => {
261
+ if (!entry.fileRef) return;
262
+ entry.status = 'finalizing';
263
+ entry.failure = null;
264
+ entry.callbackController?.abort();
265
+ const controller = new AbortController();
266
+ entry.callbackController = controller;
267
+ if (useTransferToast) progressToasts.get(entry.id)?.finalizing(`Finishing ${entry.filename}`);
268
+ publish();
269
+ try {
270
+ await callbacks.get(entry.id)?.(entry.fileRef, { queueItemId: entry.id, signal: controller.signal });
271
+ if (disposed || entry.attempt !== attempt || controller.signal.aborted) return;
272
+ entry.status = 'completed';
273
+ fileValues.delete(entry.id);
274
+ callbacks.delete(entry.id);
275
+ if (useTransferToast) finishToast(entry, 'done', `${entry.filename} uploaded`);
276
+ else toast.success(`${entry.filename} follow-up completed`);
277
+ } catch {
278
+ if (disposed || entry.attempt !== attempt || controller.signal.aborted) return;
279
+ entry.status = 'completed-warning';
280
+ entry.failure = {
281
+ stage: 'callback', code: 'callback_failed', retryable: true,
282
+ message: 'The file uploaded, but the follow-up action failed.',
283
+ };
284
+ fileValues.delete(entry.id);
285
+ if (useTransferToast) finishToast(entry, 'fail', `${entry.filename} uploaded; follow-up failed`);
286
+ else toast.warning(`${entry.filename} is uploaded, but its follow-up still needs attention`);
287
+ } finally {
288
+ if (entry.callbackController === controller) entry.callbackController = null;
289
+ if (!disposed && entry.attempt === attempt) {
290
+ if (entry.removeWhenSettled) removeEntry(entry);
291
+ publish();
292
+ }
293
+ }
294
+ };
295
+ const settleTask = async (entry: PrivateEntry, attempt: number, result: ReturnType<FileUploadTask['retry']>) => {
296
+ try {
297
+ const outcome = await result;
298
+ if (disposed || entry.attempt !== attempt) return;
299
+ activeTasks = Math.max(0, activeTasks - 1);
300
+ if (outcome.status === 'completed') {
301
+ entry.fileRef = { ...outcome.file };
302
+ entry.totalBytes = entry.totalBytes ?? entry.size;
303
+ entry.loadedBytes = entry.totalBytes;
304
+ schedule();
305
+ await beginCallback(entry, attempt, true);
306
+ } else {
307
+ entry.status = outcome.status;
308
+ entry.failure = safeFailure(outcome.failure);
309
+ finishToast(entry, 'fail', outcome.status === 'uncertain'
310
+ ? `${entry.filename}: server state needs review`
311
+ : `${entry.filename} failed`);
312
+ if (entry.removeWhenSettled) removeEntry(entry);
313
+ publish();
314
+ }
315
+ } catch {
316
+ if (disposed || entry.attempt !== attempt) return;
317
+ activeTasks = Math.max(0, activeTasks - 1);
318
+ entry.status = 'uncertain';
319
+ entry.failure = { stage: 'reconcile', code: 'remote_state_unknown', retryable: true, message: 'The server could not confirm the upload state.' };
320
+ finishToast(entry, 'fail', `${entry.filename}: server state needs review`);
321
+ publish();
322
+ } finally {
323
+ if (!disposed) schedule();
324
+ }
325
+ };
326
+ const observeTask = (entry: PrivateEntry, attempt: number, state: FileUploadSnapshot) => {
327
+ if (disposed || entry.attempt !== attempt || entry.cancelRequested) return;
328
+ // The result promise is the settlement authority. Ignoring terminal
329
+ // subscription echoes avoids a removable/retryable one-microtask gap
330
+ // before slot accounting and completion callbacks have run.
331
+ if (state.outcome) return;
332
+ entry.status = state.phase;
333
+ entry.loadedBytes = Math.max(0, state.loadedBytes);
334
+ entry.totalBytes = state.totalBytes && state.totalBytes > 0 ? state.totalBytes : null;
335
+ const pct = displayedPercent(entry.loadedBytes, entry.totalBytes);
336
+ if (state.phase === 'uploading' && pct != null && entry.loadedBytes !== entry.lastToastLoaded && pct !== entry.lastToastPercent) {
337
+ entry.lastToastLoaded = entry.loadedBytes;
338
+ entry.lastToastPercent = pct;
339
+ progressToasts.get(entry.id)?.update(pct);
340
+ }
341
+ publish();
342
+ };
343
+ const run = (entry: PrivateEntry, mode: PendingOperation['mode']) => {
344
+ const fileValue = fileValues.get(entry.id);
345
+ if (!fileValue && mode === 'start') return;
346
+ entry.attempt += 1;
347
+ const attempt = entry.attempt;
348
+ entry.cancelRequested = false;
349
+ entry.failure = null;
350
+ entry.loadedBytes = 0;
351
+ entry.totalBytes = null;
352
+ entry.lastToastLoaded = -1;
353
+ entry.lastToastPercent = -1;
354
+ activeTasks += 1;
355
+ progressToasts.set(entry.id, toast.progress(`Uploading ${entry.filename}`, { onCancel: () => store.cancel(entry.id) }));
356
+ let result: Promise<import('../client/upload').FileUploadOutcome>;
357
+ if (mode === 'start') {
358
+ const task = startTask(fileValue!, entry.destination);
359
+ tasks.set(entry.id, task);
360
+ entry.unsubscribeTask = task.subscribe((state) => observeTask(entry, attempt, state));
361
+ result = task.result;
362
+ } else {
363
+ const task = tasks.get(entry.id)!;
364
+ entry.unsubscribeTask?.();
365
+ entry.unsubscribeTask = task.subscribe((state) => observeTask(entry, attempt, state));
366
+ result = mode === 'recover' ? task.recover() : task.retry();
367
+ }
368
+ publish();
369
+ void settleTask(entry, attempt, result);
370
+ };
371
+ function schedule() {
372
+ if (disposed) return;
373
+ while (activeTasks < concurrency && pending.length) {
374
+ const operation = pending.shift()!;
375
+ const entry = find(operation.id);
376
+ if (!entry || entry.status !== 'queued') continue;
377
+ run(entry, operation.mode);
378
+ }
379
+ publish();
380
+ }
381
+ const queueOperation = (entry: PrivateEntry, mode: PendingOperation['mode']) => {
382
+ entry.status = 'queued';
383
+ pending.push({ id: entry.id, mode });
384
+ schedule();
385
+ };
386
+
387
+ const store: UploadQueueStore = {
388
+ getSnapshot: () => snapshot,
389
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
390
+ enqueue(files, enqueueOptions) {
391
+ if (disposed) return { acceptedIds: [], rejected: Array.from(files, (file) => ({ filename: file.name, code: 'capacity' as const, message: 'This upload queue is no longer available.' })) };
392
+ ensureAuthGuard();
393
+ const acceptedIds: string[] = [];
394
+ const rejected: UploadQueueRejection[] = [];
395
+ const destination = normalizeDestination(enqueueOptions.destination);
396
+ for (const file of files) {
397
+ const filename = sanitizeUploadBasename(file.name);
398
+ const dedupeKey = `${enqueueOptions.consumerKey}|${destinationKey(destination)}|${filename}|${file.size}|${file.type}|${file.lastModified}`;
399
+ if (entries.some((entry) => entry.dedupeKey === dedupeKey)) {
400
+ rejected.push({ filename, code: 'duplicate', message: `${filename} is already in this destination's queue.` });
401
+ continue;
402
+ }
403
+ if (entries.length >= capacity) {
404
+ rejected.push({ filename, code: 'capacity', message: `The upload queue can hold ${capacity} files.` });
405
+ continue;
406
+ }
407
+ const id = `upload-${nextId++}`;
408
+ const entry: PrivateEntry = {
409
+ id, filename, contentType: file.type || 'application/octet-stream', size: file.size,
410
+ dedupeKey, destination, callbackController: null,
411
+ unsubscribeTask: null, status: 'queued', loadedBytes: 0,
412
+ totalBytes: null, fileRef: null, failure: null, attempt: 0, lastToastLoaded: -1,
413
+ lastToastPercent: -1, cancelRequested: false, removeWhenSettled: false,
414
+ };
415
+ entries.push(entry);
416
+ fileValues.set(id, file);
417
+ if (enqueueOptions.onComplete) callbacks.set(id, enqueueOptions.onComplete);
418
+ pending.push({ id, mode: 'start' });
419
+ acceptedIds.push(id);
420
+ }
421
+ schedule();
422
+ return { acceptedIds, rejected };
423
+ },
424
+ cancel(id) {
425
+ const entry = find(id);
426
+ if (!entry) return;
427
+ if (entry.status === 'queued') {
428
+ for (let i = pending.length - 1; i >= 0; i--) if (pending[i]!.id === id) pending.splice(i, 1);
429
+ entry.status = 'cancelled';
430
+ fileValues.delete(entry.id);
431
+ publish();
432
+ return;
433
+ }
434
+ if (!ACTIVE.has(entry.status) || entry.status === 'finalizing' || entry.status === 'cancelling') return;
435
+ entry.cancelRequested = true;
436
+ entry.status = 'cancelling';
437
+ progressToasts.get(entry.id)?.finalizing(`Cancelling ${entry.filename}`);
438
+ tasks.get(entry.id)?.cancel();
439
+ publish();
440
+ },
441
+ retry(id) {
442
+ const entry = find(id);
443
+ if (!entry) return;
444
+ if (entry.status === 'completed-warning' && callbacks.has(entry.id) && entry.fileRef) {
445
+ entry.attempt += 1;
446
+ void beginCallback(entry, entry.attempt, false);
447
+ return;
448
+ }
449
+ if ((entry.status === 'failed' || entry.status === 'uncertain') && entry.failure?.retryable && tasks.has(entry.id)) queueOperation(entry, 'retry');
450
+ },
451
+ recover(id) {
452
+ const entry = find(id);
453
+ if (entry?.status === 'uncertain' && tasks.get(entry.id)?.getSnapshot().fileId != null) queueOperation(entry, 'recover');
454
+ },
455
+ remove(id) {
456
+ const entry = find(id);
457
+ if (!entry) return;
458
+ if (ACTIVE.has(entry.status)) {
459
+ entry.removeWhenSettled = true;
460
+ store.cancel(id);
461
+ return;
462
+ }
463
+ removeEntry(entry);
464
+ publish();
465
+ },
466
+ cancelAll() { for (const entry of [...entries]) store.cancel(entry.id); },
467
+ retryAll() { for (const entry of [...entries]) if (entry.status === 'failed' || entry.status === 'uncertain' || entry.status === 'completed-warning') store.retry(entry.id); },
468
+ clearSettled() {
469
+ for (const entry of [...entries]) if (SETTLED.has(entry.status)) removeEntry(entry);
470
+ publish();
471
+ },
472
+ mount() {
473
+ if (disposed) return () => {};
474
+ mountCount += 1;
475
+ disposeGeneration += 1;
476
+ let released = false;
477
+ return () => {
478
+ if (released) return;
479
+ released = true;
480
+ mountCount = Math.max(0, mountCount - 1);
481
+ const generation = ++disposeGeneration;
482
+ queueMicrotask(() => { if (mountCount === 0 && disposeGeneration === generation) store.dispose(); });
483
+ };
484
+ },
485
+ dispose() {
486
+ if (disposed) return;
487
+ disposed = true;
488
+ unsubscribeAuth?.();
489
+ unsubscribeAuth = null;
490
+ for (const entry of entries) {
491
+ entry.attempt += 1;
492
+ entry.callbackController?.abort();
493
+ tasks.get(entry.id)?.cancel();
494
+ entry.unsubscribeTask?.();
495
+ progressToasts.get(entry.id)?.remove();
496
+ }
497
+ fileValues.clear();
498
+ tasks.clear();
499
+ callbacks.clear();
500
+ progressToasts.clear();
501
+ entries.length = 0;
502
+ pending.length = 0;
503
+ activeTasks = 0;
504
+ snapshot = EMPTY_SNAPSHOT;
505
+ for (const listener of listeners) listener();
506
+ listeners.clear();
507
+ },
508
+ };
509
+ return store;
510
+ }
511
+
512
+ export interface UseUploadQueueOptions extends UploadQueueOptions {
513
+ consumerKey?: string;
514
+ destination?: StartFileUploadOptions;
515
+ onComplete?: UploadQueueCompletion;
516
+ }
517
+
518
+ export interface UploadQueueController extends UploadQueueStore {
519
+ snapshot: UploadQueueSnapshot;
520
+ add(files: Iterable<File>): UploadQueueEnqueueResult;
521
+ }
522
+
523
+ export function useUploadQueue(options: UseUploadQueueOptions = {}): UploadQueueController {
524
+ const generatedKey = `upload-consumer-${useId().replace(/:/g, '')}`;
525
+ const current = useRef(options);
526
+ current.current = options;
527
+ const queue = useRef<UploadQueueStore | null>(null);
528
+ if (!queue.current) queue.current = createUploadQueue(options);
529
+ useEffect(() => queue.current!.mount(), []);
530
+ const snapshot = useSyncExternalStore(queue.current.subscribe, queue.current.getSnapshot, queue.current.getSnapshot);
531
+ const add = useCallback((files: Iterable<File>) => queue.current!.enqueue(files, {
532
+ consumerKey: current.current.consumerKey ?? generatedKey,
533
+ destination: current.current.destination,
534
+ onComplete: current.current.onComplete,
535
+ }), [generatedKey]);
536
+ return Object.assign(queue.current, { snapshot, add });
537
+ }
538
+
539
+ export interface UploadQueueProps {
540
+ queue: Pick<UploadQueueController, 'snapshot' | 'cancel' | 'retry' | 'recover' | 'remove' | 'cancelAll' | 'retryAll' | 'clearSettled'>;
541
+ className?: string;
542
+ empty?: ReactNode;
543
+ }
544
+
545
+ function statusLabel(item: UploadQueueItem): string {
546
+ const labels: Record<UploadQueueItemStatus, string> = {
547
+ queued: 'Queued', initiating: 'Starting', uploading: 'Uploading', reconciling: 'Checking',
548
+ completing: 'Completing', cancelling: 'Cancellation requested', finalizing: 'Finalizing',
549
+ completed: 'Completed', 'completed-warning': 'Uploaded · follow-up failed', failed: 'Failed',
550
+ cancelled: 'Cancelled', uncertain: 'Needs recovery',
551
+ };
552
+ return labels[item.status];
553
+ }
554
+
555
+ export function UploadQueue({ queue, className, empty = null }: UploadQueueProps) {
556
+ const { snapshot } = queue;
557
+ if (!snapshot.items.length) return <>{empty}</>;
558
+ return (
559
+ <section className={`upload-queue${className ? ` ${className}` : ''}`} aria-label="Upload queue">
560
+ <header className="upload-queue-head">
561
+ <div>
562
+ <strong>{snapshot.activeCount ? `${snapshot.activeCount} uploading` : `${snapshot.completedCount} completed`}</strong>
563
+ <span>{snapshot.queuedCount ? ` · ${snapshot.queuedCount} queued` : ''}{snapshot.failedCount ? ` · ${snapshot.failedCount} failed` : ''}</span>
564
+ </div>
565
+ <div className="upload-queue-actions">
566
+ {snapshot.items.some((item) => item.canCancel) && <button type="button" className="btn btn-compact" onClick={queue.cancelAll}>Cancel all</button>}
567
+ {snapshot.items.some((item) => item.canRetry) && <button type="button" className="btn btn-compact" onClick={queue.retryAll}>Retry available</button>}
568
+ {snapshot.items.some((item) => item.canRemove) && <button type="button" className="btn btn-compact" onClick={queue.clearSettled}>Clear settled</button>}
569
+ </div>
570
+ </header>
571
+ {snapshot.totalBytes != null && (
572
+ <div className="upload-aggregate" role="progressbar" aria-label="Overall upload progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={snapshot.percent ?? 0}>
573
+ <span style={{ width: `${snapshot.percent ?? 0}%` }} />
574
+ </div>
575
+ )}
576
+ <ul className="upload-list">
577
+ {snapshot.items.map((item) => (
578
+ <li key={item.id} className={`upload-item is-${item.status}`}>
579
+ <i className="bi bi-file-earmark upload-file-icon" aria-hidden="true" />
580
+ <div className="upload-item-main">
581
+ <div className="upload-item-title"><strong title={item.filename}>{item.filename}</strong><span>{filesize(item.size)}</span></div>
582
+ <div className="upload-item-status">
583
+ <span>{statusLabel(item)}{item.percent != null && item.status === 'uploading' ? ` · ${item.percent}%` : ''}</span>
584
+ {item.failure && <span className="upload-item-message">{item.failure.message}</span>}
585
+ </div>
586
+ {item.totalBytes != null && ACTIVE.has(item.status) && (
587
+ <div className="upload-item-progress" role="progressbar" aria-label={`${item.filename} progress`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={item.percent ?? 0}>
588
+ <span style={{ width: `${item.percent ?? 0}%` }} />
589
+ </div>
590
+ )}
591
+ </div>
592
+ <div className="upload-item-actions">
593
+ {item.canCancel && <button type="button" className="btn-icon" aria-label={`Cancel ${item.filename}`} title="Cancel" onClick={() => queue.cancel(item.id)}><i className="bi bi-x-lg" /></button>}
594
+ {item.canRecover && <button type="button" className="btn btn-compact" onClick={() => queue.recover(item.id)}>Recover</button>}
595
+ {item.canRetry && <button type="button" className="btn btn-compact" onClick={() => queue.retry(item.id)}>{item.status === 'completed-warning' ? 'Retry follow-up' : 'Retry'}</button>}
596
+ {item.canRemove && <button type="button" className="btn-icon" aria-label={`Remove ${item.filename}`} title="Remove" onClick={() => queue.remove(item.id)}><i className="bi bi-trash3" /></button>}
597
+ </div>
598
+ </li>
599
+ ))}
600
+ </ul>
601
+ </section>
602
+ );
603
+ }