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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// DetailView + SideNav — the record-viewer layout: flat header (avatar,
|
|
2
|
+
// title, subtitle, chips, active switch, kebab menu, close) over a left rail
|
|
3
|
+
// of grouped sections. Direct port of web-mojo's headerConfig/sections schema
|
|
4
|
+
// (SideNavView.js + ContextMenu.js); the section bodies are plain components
|
|
5
|
+
// built from FlatRow / SecurityItem.
|
|
6
|
+
//
|
|
7
|
+
// C1 semantics (ported from SideNavView.js:115-160,328-438 and
|
|
8
|
+
// ContextMenu.js:102-116):
|
|
9
|
+
// · Sections may carry `permissions` (any-of when array) — resolved through
|
|
10
|
+
// the useCan path (me + active-group member), FAIL-CLOSED: no resolvable
|
|
11
|
+
// grant ⇒ the section is absent from the rail AND unroutable. Divider
|
|
12
|
+
// labels whose following group has no visible section drop with it.
|
|
13
|
+
// · If the ACTIVE section becomes invisible (permission or schema change),
|
|
14
|
+
// selection self-heals to the first visible section — and the heal is
|
|
15
|
+
// STICKY (source `_reconcileActiveSection` rewrote activeSection): the
|
|
16
|
+
// old section regaining visibility does not steal selection back.
|
|
17
|
+
// · Rail badges are a CONTROLLED prop (`badges`), keyed by section key.
|
|
18
|
+
// Deviation from source, by design: web-mojo's imperative
|
|
19
|
+
// `setBadge(key, value)` patched the DOM; here React owns the state —
|
|
20
|
+
// update the prop and the rail follows. Primitive values render as the
|
|
21
|
+
// source's muted pill; ReactNode values render as given.
|
|
22
|
+
// · `contextMenu` renders a header kebab opening a Popover menu. Entries
|
|
23
|
+
// filter by `permissions` (any-of, fail-closed) ∧ `when(menuContext)`;
|
|
24
|
+
// hidden entries drop; separators that end up leading, trailing, or
|
|
25
|
+
// doubled collapse; the menu closes on select.
|
|
26
|
+
// · Lazy mount + keep-alive: a section's content mounts on FIRST
|
|
27
|
+
// activation and stays mounted-but-CSS-hidden afterwards, so section
|
|
28
|
+
// state survives switching — the source's unmount-not-destroy semantic.
|
|
29
|
+
// Sections hidden by permissions unmount entirely (fail-closed beats
|
|
30
|
+
// keep-alive; revoked content must not linger in the DOM).
|
|
31
|
+
// · Invalid config (bad permission shapes, unknown badge keys, malformed
|
|
32
|
+
// menu entries) falls back WITH a console.warn — never silently. Each
|
|
33
|
+
// distinct problem warns once per instance, not once per render.
|
|
34
|
+
//
|
|
35
|
+
// Note: DetailView now resolves permissions, so (like the rest of the
|
|
36
|
+
// toolkit's client-bound components) it must render inside the app's
|
|
37
|
+
// QueryClientProvider. Both are baseline requirements of the package.
|
|
38
|
+
import { useContext, useRef, useState, type ReactNode } from 'react';
|
|
39
|
+
import { initials } from './format';
|
|
40
|
+
import type { Tone } from './format';
|
|
41
|
+
import { hasPermission, useMe } from '../client/me';
|
|
42
|
+
import { GroupContext } from '../client/group-context';
|
|
43
|
+
import { Popover } from './Popover';
|
|
44
|
+
|
|
45
|
+
export interface Chip {
|
|
46
|
+
icon?: string;
|
|
47
|
+
text: string;
|
|
48
|
+
tone?: Tone;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Section {
|
|
52
|
+
key: string;
|
|
53
|
+
label: string;
|
|
54
|
+
icon: string;
|
|
55
|
+
render: () => ReactNode;
|
|
56
|
+
/** Permission gate — ANY-of when an array; resolved like `useCan`
|
|
57
|
+
* (signed-in user + active-group member), FAIL-CLOSED: while `me`
|
|
58
|
+
* loads, while anonymous, and on a failed check the section is
|
|
59
|
+
* absent from the rail and cannot be activated. */
|
|
60
|
+
permissions?: string | string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type RailEntry = Section | { divider: string };
|
|
64
|
+
|
|
65
|
+
/** Header kebab menu entry. `{ divider: true }` renders a separator. */
|
|
66
|
+
export interface DetailMenuItem<TCtx = unknown> {
|
|
67
|
+
label: string;
|
|
68
|
+
icon?: string;
|
|
69
|
+
/** Same any-of, fail-closed gate as `Section.permissions`. */
|
|
70
|
+
permissions?: string | string[];
|
|
71
|
+
/** Visibility predicate over `menuContext` (the record on display).
|
|
72
|
+
* Re-evaluated every render, so entries track model state live.
|
|
73
|
+
* Receives `undefined` when no `menuContext` prop is given. */
|
|
74
|
+
when?: (ctx: TCtx | undefined) => boolean;
|
|
75
|
+
onSelect: () => void;
|
|
76
|
+
/** Destructive styling (`--bad`), the source's `danger` flag. */
|
|
77
|
+
danger?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DetailMenuEntry<TCtx = unknown> = DetailMenuItem<TCtx> | { divider: true };
|
|
81
|
+
|
|
82
|
+
export interface DetailViewProps<TCtx = unknown> {
|
|
83
|
+
avatarName?: string;
|
|
84
|
+
icon?: string;
|
|
85
|
+
title: string;
|
|
86
|
+
subtitle?: string;
|
|
87
|
+
chips?: Chip[];
|
|
88
|
+
active?: { value: boolean; onChange: (next: boolean) => void };
|
|
89
|
+
sections: RailEntry[];
|
|
90
|
+
initialSection?: string;
|
|
91
|
+
/** Controlled rail badges keyed by section key — numbers, dots, text,
|
|
92
|
+
* any ReactNode. Nullish / `false` / `''` / `true` render nothing
|
|
93
|
+
* (web-mojo `_normalizeBadge` parity). */
|
|
94
|
+
badges?: Record<string, ReactNode>;
|
|
95
|
+
/** Header kebab menu. Renders only when at least one entry survives
|
|
96
|
+
* permissions ∧ when() filtering — an empty menu shows no kebab. */
|
|
97
|
+
contextMenu?: DetailMenuEntry<TCtx>[];
|
|
98
|
+
/** Handed to every `when(ctx)` predicate — by convention the record on
|
|
99
|
+
* display (web-mojo's `this.context`). */
|
|
100
|
+
menuContext?: TCtx;
|
|
101
|
+
onClose: () => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Config validation ─────────────────────────────────────────────────
|
|
105
|
+
// The do-not-recreate rule: unknown/invalid config falls back WITH a
|
|
106
|
+
// console.warn — never to silence, and (for permissions) never to open.
|
|
107
|
+
|
|
108
|
+
type WarnOnce = (key: string, message: string) => void;
|
|
109
|
+
|
|
110
|
+
/** Warn once per (instance, problem) — loud, but not once per render. */
|
|
111
|
+
function useWarnOnce(): WarnOnce {
|
|
112
|
+
const seen = useRef<Set<string>>(new Set());
|
|
113
|
+
return (key, message) => {
|
|
114
|
+
if (seen.current.has(key)) return;
|
|
115
|
+
seen.current.add(key);
|
|
116
|
+
console.warn(message);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate a `permissions` value into what `hasPermission` accepts.
|
|
122
|
+
* Invalid shapes warn and yield `[]` — any-of over nothing, which DENIES
|
|
123
|
+
* for everyone except superusers/`admin` holders (who pass every gate in
|
|
124
|
+
* this framework). Fail-closed, matching View#checkPermissions.
|
|
125
|
+
*/
|
|
126
|
+
function normalizePermissions(raw: string | string[], where: string, warnOnce: WarnOnce): string | string[] {
|
|
127
|
+
if (typeof raw === 'string') return raw;
|
|
128
|
+
if (Array.isArray(raw)) {
|
|
129
|
+
const strings = raw.filter((p): p is string => typeof p === 'string');
|
|
130
|
+
if (strings.length !== raw.length) {
|
|
131
|
+
warnOnce(`perm-shape:${where}`,
|
|
132
|
+
`DetailView: ${where} has non-string entries in its permissions array — ignoring them (fail-closed).`);
|
|
133
|
+
}
|
|
134
|
+
return strings;
|
|
135
|
+
}
|
|
136
|
+
warnOnce(`perm-shape:${where}`,
|
|
137
|
+
`DetailView: ${where} has an invalid permissions value (expected string or string[]) — treating as denied (fail-closed).`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `useCan` resolution path (signed-in user + active-group member),
|
|
143
|
+
* returned as a plain checker so a dynamic schema can be gated without
|
|
144
|
+
* running hooks in a loop. Fail-closed by construction: anonymous or
|
|
145
|
+
* still-loading `me` ⇒ every check is false.
|
|
146
|
+
*/
|
|
147
|
+
function useCanChecker(): (permission: string | string[]) => boolean {
|
|
148
|
+
const { data } = useMe();
|
|
149
|
+
const member = useContext(GroupContext)?.member ?? null;
|
|
150
|
+
const me = data ?? null;
|
|
151
|
+
return (permission) => hasPermission(me, permission, member);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Menu filtering (ContextMenu.visibleItems port) ────────────────────
|
|
155
|
+
|
|
156
|
+
function isMenuDivider<TCtx>(entry: DetailMenuEntry<TCtx>): entry is { divider: true } {
|
|
157
|
+
return 'divider' in entry && Boolean(entry.divider);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The entries the current user/context may see, in order. Two gates,
|
|
162
|
+
* matching the source: `permissions` (any-of, fail-closed) and
|
|
163
|
+
* `when(menuContext)` — then separators that end up leading, trailing, or
|
|
164
|
+
* doubled are dropped so the menu never shows an orphaned rule.
|
|
165
|
+
*/
|
|
166
|
+
function visibleMenuEntries<TCtx>(
|
|
167
|
+
entries: DetailMenuEntry<TCtx>[],
|
|
168
|
+
can: (permission: string | string[]) => boolean,
|
|
169
|
+
ctx: TCtx | undefined,
|
|
170
|
+
warnOnce: WarnOnce,
|
|
171
|
+
): DetailMenuEntry<TCtx>[] {
|
|
172
|
+
const kept = entries.filter((entry, i) => {
|
|
173
|
+
if (isMenuDivider(entry)) return true;
|
|
174
|
+
const item = entry as DetailMenuItem<TCtx>;
|
|
175
|
+
if (typeof item.label !== 'string' || typeof item.onSelect !== 'function') {
|
|
176
|
+
warnOnce(`menu-entry:${i}`,
|
|
177
|
+
`DetailView: contextMenu entry #${i} needs { label, onSelect } (or { divider: true }) — dropped.`);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (item.permissions !== undefined
|
|
181
|
+
&& !can(normalizePermissions(item.permissions, `contextMenu "${item.label}"`, warnOnce))) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (item.when !== undefined) {
|
|
185
|
+
if (typeof item.when !== 'function') {
|
|
186
|
+
warnOnce(`menu-when:${item.label}`,
|
|
187
|
+
`DetailView: contextMenu "${item.label}" has a non-function \`when\` — ignoring the predicate.`);
|
|
188
|
+
} else if (!item.when(ctx)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
return kept.filter((entry, i) => {
|
|
195
|
+
if (!isMenuDivider(entry)) return true;
|
|
196
|
+
if (i === 0) return false;
|
|
197
|
+
const next = kept[i + 1];
|
|
198
|
+
return next !== undefined && !isMenuDivider(next);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Rail badges ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Controlled-badge renderer. Primitive values get the source's default
|
|
206
|
+
* muted pill (`_normalizeBadge` normalized numbers/strings to the muted
|
|
207
|
+
* variant); element values render as given, so `<Badge tone>` / dots /
|
|
208
|
+
* custom nodes own their look. Falsy-but-renderable values (`0` excepted)
|
|
209
|
+
* render nothing, like the source.
|
|
210
|
+
*/
|
|
211
|
+
function railBadge(value: ReactNode): ReactNode {
|
|
212
|
+
if (value === null || value === undefined || value === false || value === true || value === '') return null;
|
|
213
|
+
const pill = typeof value === 'string' || typeof value === 'number';
|
|
214
|
+
return <span className="rail-badge">{pill ? <span className="chip chip-muted">{value}</span> : value}</span>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── The component ─────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
export function DetailView<TCtx = unknown>({
|
|
220
|
+
avatarName, icon, title, subtitle, chips = [], active,
|
|
221
|
+
sections, initialSection, badges, contextMenu, menuContext, onClose,
|
|
222
|
+
}: DetailViewProps<TCtx>) {
|
|
223
|
+
const can = useCanChecker();
|
|
224
|
+
const warnOnce = useWarnOnce();
|
|
225
|
+
|
|
226
|
+
// One-time schema lint: duplicate keys break activation + keep-alive
|
|
227
|
+
// keying; badge keys must name a section; a never-matching
|
|
228
|
+
// initialSection is a typo (a merely GATED one heals silently — that
|
|
229
|
+
// is normal permission behavior, not misconfig).
|
|
230
|
+
const realAll = sections.filter((s): s is Section => !('divider' in s));
|
|
231
|
+
{
|
|
232
|
+
const seen = new Set<string>();
|
|
233
|
+
for (const s of realAll) {
|
|
234
|
+
if (seen.has(s.key)) warnOnce(`dup:${s.key}`, `DetailView: duplicate section key "${s.key}" — activation and keep-alive key on it.`);
|
|
235
|
+
seen.add(s.key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (badges) {
|
|
239
|
+
for (const key of Object.keys(badges)) {
|
|
240
|
+
if (!realAll.some((s) => s.key === key)) {
|
|
241
|
+
warnOnce(`badge:${key}`, `DetailView: badges key "${key}" matches no section — ignored.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (initialSection != null && realAll.length > 0 && !realAll.some((s) => s.key === initialSection)) {
|
|
246
|
+
warnOnce(`initial:${initialSection}`,
|
|
247
|
+
`DetailView: initialSection "${initialSection}" matches no section — falling back to the first visible one.`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Fail-closed visibility, then SideNavView's divider rule: a divider
|
|
251
|
+
// survives only when the next visible entry is a section (trailing and
|
|
252
|
+
// doubled dividers drop; a leading one with content below stays).
|
|
253
|
+
const sectionAllowed = (s: Section): boolean =>
|
|
254
|
+
s.permissions === undefined || can(normalizePermissions(s.permissions, `section "${s.key}"`, warnOnce));
|
|
255
|
+
const allowed = sections.filter((e) => 'divider' in e || sectionAllowed(e));
|
|
256
|
+
const visibleEntries = allowed.filter((e, i) => {
|
|
257
|
+
if (!('divider' in e)) return true;
|
|
258
|
+
const next = allowed[i + 1];
|
|
259
|
+
return next !== undefined && !('divider' in next);
|
|
260
|
+
});
|
|
261
|
+
const visibleSections = visibleEntries.filter((e): e is Section => !('divider' in e));
|
|
262
|
+
|
|
263
|
+
// Active selection + self-heal (source _reconcileActiveSection): when
|
|
264
|
+
// the selected key is missing or gated, fall back to the first visible
|
|
265
|
+
// section — and WRITE the heal back so it sticks (guarded render-time
|
|
266
|
+
// setState; converges in one extra pass).
|
|
267
|
+
const [activeKey, setActiveKey] = useState<string | null>(initialSection ?? null);
|
|
268
|
+
const effectiveKey = activeKey !== null && visibleSections.some((s) => s.key === activeKey)
|
|
269
|
+
? activeKey
|
|
270
|
+
: visibleSections[0]?.key ?? null;
|
|
271
|
+
if (effectiveKey !== activeKey) setActiveKey(effectiveKey);
|
|
272
|
+
|
|
273
|
+
// Lazy mount + keep-alive: a section joins `activated` on first
|
|
274
|
+
// activation and stays mounted-but-hidden from then on. Only VISIBLE
|
|
275
|
+
// sections mount at all — losing a permission unmounts (and drops the
|
|
276
|
+
// local state of) the revoked section.
|
|
277
|
+
const [activated, setActivated] = useState<ReadonlySet<string>>(() => new Set());
|
|
278
|
+
if (effectiveKey !== null && !activated.has(effectiveKey)) {
|
|
279
|
+
setActivated(new Set(activated).add(effectiveKey));
|
|
280
|
+
}
|
|
281
|
+
const mountedSections = visibleSections.filter((s) => activated.has(s.key));
|
|
282
|
+
|
|
283
|
+
// Header kebab (ContextMenu port) — no surviving entries, no button.
|
|
284
|
+
const kebabRef = useRef<HTMLButtonElement>(null);
|
|
285
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
286
|
+
const menuEntries = contextMenu ? visibleMenuEntries(contextMenu, can, menuContext, warnOnce) : [];
|
|
287
|
+
// If every entry filters away WHILE the menu is open (live `when()` /
|
|
288
|
+
// permission flip), the kebab unmounts — reset `open` so the menu can't
|
|
289
|
+
// spring back uninvited when entries return.
|
|
290
|
+
if (menuOpen && menuEntries.length === 0) setMenuOpen(false);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div className="detail">
|
|
294
|
+
<header className="detail-header">
|
|
295
|
+
<div className="detail-avatar">
|
|
296
|
+
{avatarName ? initials(avatarName) : <i className={`bi ${icon ?? 'bi-file-earmark'}`} />}
|
|
297
|
+
</div>
|
|
298
|
+
<div className="detail-id">
|
|
299
|
+
<h2 className="detail-title">{title}</h2>
|
|
300
|
+
<div className="detail-sub">
|
|
301
|
+
{subtitle}
|
|
302
|
+
{chips.map((chip, i) => (
|
|
303
|
+
<span key={i} className={`chip chip-${chip.tone ?? 'muted'}`}>
|
|
304
|
+
{chip.icon && <i className={`bi ${chip.icon}`} />} {chip.text}
|
|
305
|
+
</span>
|
|
306
|
+
))}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="detail-gutter">
|
|
310
|
+
{active && (
|
|
311
|
+
<label className="switch-inline" title={active.value ? 'Active' : 'Inactive'}>
|
|
312
|
+
<input
|
|
313
|
+
type="checkbox"
|
|
314
|
+
role="switch"
|
|
315
|
+
className="switch"
|
|
316
|
+
checked={active.value}
|
|
317
|
+
onChange={(e) => active.onChange(e.target.checked)}
|
|
318
|
+
/>
|
|
319
|
+
</label>
|
|
320
|
+
)}
|
|
321
|
+
{menuEntries.length > 0 && (
|
|
322
|
+
<>
|
|
323
|
+
<button
|
|
324
|
+
ref={kebabRef}
|
|
325
|
+
className="btn-icon dv-kebab"
|
|
326
|
+
aria-haspopup="menu"
|
|
327
|
+
aria-expanded={menuOpen}
|
|
328
|
+
title="More actions"
|
|
329
|
+
aria-label="More actions"
|
|
330
|
+
onClick={() => setMenuOpen((v) => !v)}
|
|
331
|
+
>
|
|
332
|
+
<i className="bi bi-three-dots-vertical" />
|
|
333
|
+
</button>
|
|
334
|
+
<Popover
|
|
335
|
+
anchorRef={kebabRef}
|
|
336
|
+
open={menuOpen}
|
|
337
|
+
onClose={() => setMenuOpen(false)}
|
|
338
|
+
placement="bottom-end"
|
|
339
|
+
role="menu"
|
|
340
|
+
aria-label="More actions"
|
|
341
|
+
>
|
|
342
|
+
<div className="dv-menu">
|
|
343
|
+
{menuEntries.map((entry, i) => isMenuDivider(entry) ? (
|
|
344
|
+
<div key={`d${i}`} className="dv-menu-sep" role="separator" />
|
|
345
|
+
) : (
|
|
346
|
+
<button
|
|
347
|
+
key={`${entry.label}${i}`}
|
|
348
|
+
className={`dv-menu-item${entry.danger ? ' dv-danger' : ''}`}
|
|
349
|
+
role="menuitem"
|
|
350
|
+
// Source order: run the handler, then close.
|
|
351
|
+
onClick={() => { entry.onSelect(); setMenuOpen(false); }}
|
|
352
|
+
>
|
|
353
|
+
{entry.icon && <i className={`bi ${entry.icon}`} />}
|
|
354
|
+
<span>{entry.label}</span>
|
|
355
|
+
</button>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
</Popover>
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
361
|
+
<button className="btn-icon" onClick={onClose} title="Close" aria-label="Close">
|
|
362
|
+
<i className="bi bi-x-lg" />
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</header>
|
|
366
|
+
<div className="detail-body">
|
|
367
|
+
<nav className="detail-rail" aria-label={`${title} sections`}>
|
|
368
|
+
{visibleEntries.map((entry, i) => 'divider' in entry ? (
|
|
369
|
+
<div key={`d${i}`} className="rail-divider">{entry.divider}</div>
|
|
370
|
+
) : (
|
|
371
|
+
<button
|
|
372
|
+
key={entry.key}
|
|
373
|
+
className={`rail-item${entry.key === effectiveKey ? ' rail-active' : ''}`}
|
|
374
|
+
onClick={() => setActiveKey(entry.key)}
|
|
375
|
+
>
|
|
376
|
+
<i className={`bi ${entry.icon}`} /> {entry.label}
|
|
377
|
+
{railBadge(badges?.[entry.key])}
|
|
378
|
+
</button>
|
|
379
|
+
))}
|
|
380
|
+
</nav>
|
|
381
|
+
<div className="detail-content">
|
|
382
|
+
{mountedSections.map((s) => (
|
|
383
|
+
<div key={s.key} className="dv-keep" hidden={s.key !== effectiveKey}>
|
|
384
|
+
{s.render()}
|
|
385
|
+
</div>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Uppercase group heading inside a section (CONTACT / ACCOUNT / …). */
|
|
394
|
+
export function Eyebrow({ children }: { children: ReactNode }) {
|
|
395
|
+
return <div className="eyebrow section-eyebrow">{children}</div>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The canonical label / value / action row. */
|
|
399
|
+
export function FlatRow({ label, children, action, actionIcon = 'bi-pencil', actionTitle }: {
|
|
400
|
+
label: string;
|
|
401
|
+
children: ReactNode;
|
|
402
|
+
action?: () => void;
|
|
403
|
+
actionIcon?: string;
|
|
404
|
+
actionTitle?: string;
|
|
405
|
+
}) {
|
|
406
|
+
return (
|
|
407
|
+
<div className="flat-row">
|
|
408
|
+
<div className="flat-label">{label}</div>
|
|
409
|
+
<div className="flat-value">{children}</div>
|
|
410
|
+
<div className="flat-action">
|
|
411
|
+
{action && (
|
|
412
|
+
<button className="btn-icon btn-icon-sm" onClick={action} title={actionTitle ?? `Edit ${label.toLowerCase()}`}>
|
|
413
|
+
<i className={`bi ${actionIcon}`} />
|
|
414
|
+
</button>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Richer icon row: icon / title + description / trailing content. */
|
|
422
|
+
export function SecurityItem({ icon, title, desc, children }: {
|
|
423
|
+
icon: string;
|
|
424
|
+
title: string;
|
|
425
|
+
desc: string;
|
|
426
|
+
children?: ReactNode;
|
|
427
|
+
}) {
|
|
428
|
+
return (
|
|
429
|
+
<div className="sec-item">
|
|
430
|
+
<div className="sec-icon"><i className={`bi ${icon}`} /></div>
|
|
431
|
+
<div className="sec-info">
|
|
432
|
+
<div className="sec-title">{title}</div>
|
|
433
|
+
<div className="sec-desc">{desc}</div>
|
|
434
|
+
</div>
|
|
435
|
+
<div className="sec-right">{children}</div>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback, useEffect, useId, useRef, useState,
|
|
3
|
+
type ChangeEvent, type DragEvent, type ReactNode,
|
|
4
|
+
} from 'react';
|
|
5
|
+
|
|
6
|
+
export type FileSelectionRejectionCode = 'type' | 'size' | 'count';
|
|
7
|
+
|
|
8
|
+
export interface FileSelectionRejection {
|
|
9
|
+
filename: string;
|
|
10
|
+
code: FileSelectionRejectionCode;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FileSelectionRules {
|
|
15
|
+
/** MIME patterns (`image/*`), exact MIME types, or extensions (`.pdf`). */
|
|
16
|
+
accept?: string | string[];
|
|
17
|
+
maxFileSize?: number;
|
|
18
|
+
/** Maximum files accepted from one gesture. */
|
|
19
|
+
maxFiles?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileSelectionResult {
|
|
23
|
+
accepted: File[];
|
|
24
|
+
rejected: FileSelectionRejection[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const asAcceptList = (accept: FileSelectionRules['accept']): string[] =>
|
|
28
|
+
(Array.isArray(accept) ? accept : String(accept ?? '').split(','))
|
|
29
|
+
.map((value) => value.trim().toLowerCase()).filter(Boolean);
|
|
30
|
+
|
|
31
|
+
function matchesAccept(file: File, patterns: string[]): boolean {
|
|
32
|
+
if (!patterns.length || patterns.includes('*/*')) return true;
|
|
33
|
+
const type = file.type.toLowerCase();
|
|
34
|
+
const name = file.name.toLowerCase();
|
|
35
|
+
return patterns.some((pattern) => {
|
|
36
|
+
if (pattern.startsWith('.')) return name.endsWith(pattern);
|
|
37
|
+
if (pattern.endsWith('/*')) return type.startsWith(`${pattern.slice(0, -1)}`);
|
|
38
|
+
return type !== '' && type === pattern;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure, browser-independent selection validation. `accept` remains UX; the server is authoritative. */
|
|
43
|
+
export function validateFileSelection(files: Iterable<File>, rules: FileSelectionRules = {}): FileSelectionResult {
|
|
44
|
+
const patterns = asAcceptList(rules.accept);
|
|
45
|
+
const limit = Number.isSafeInteger(rules.maxFiles) && Number(rules.maxFiles) > 0
|
|
46
|
+
? Number(rules.maxFiles) : Number.POSITIVE_INFINITY;
|
|
47
|
+
const maxSize = Number.isFinite(rules.maxFileSize) && Number(rules.maxFileSize) >= 0
|
|
48
|
+
? Number(rules.maxFileSize) : Number.POSITIVE_INFINITY;
|
|
49
|
+
const accepted: File[] = [];
|
|
50
|
+
const rejected: FileSelectionRejection[] = [];
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (accepted.length >= limit) {
|
|
53
|
+
rejected.push({ filename: file.name, code: 'count', message: `Only ${limit} file${limit === 1 ? '' : 's'} can be selected at once.` });
|
|
54
|
+
} else if (!matchesAccept(file, patterns)) {
|
|
55
|
+
rejected.push({ filename: file.name, code: 'type', message: `${file.name} is not an accepted file type.` });
|
|
56
|
+
} else if (file.size > maxSize) {
|
|
57
|
+
rejected.push({ filename: file.name, code: 'size', message: `${file.name} exceeds the maximum file size.` });
|
|
58
|
+
} else {
|
|
59
|
+
accepted.push(file);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { accepted, rejected };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isFileDrag(event: globalThis.DragEvent): boolean {
|
|
66
|
+
const types = event.dataTransfer?.types;
|
|
67
|
+
return Boolean(types && Array.from(types).includes('Files'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let navigationGuardUsers = 0;
|
|
71
|
+
const guardFileNavigation = (event: globalThis.DragEvent) => {
|
|
72
|
+
if (isFileDrag(event)) event.preventDefault();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function retainFileNavigationGuard(): () => void {
|
|
76
|
+
if (typeof window === 'undefined') return () => {};
|
|
77
|
+
navigationGuardUsers += 1;
|
|
78
|
+
if (navigationGuardUsers === 1) {
|
|
79
|
+
window.addEventListener('dragover', guardFileNavigation);
|
|
80
|
+
window.addEventListener('drop', guardFileNavigation);
|
|
81
|
+
}
|
|
82
|
+
return () => {
|
|
83
|
+
navigationGuardUsers = Math.max(0, navigationGuardUsers - 1);
|
|
84
|
+
if (navigationGuardUsers === 0) {
|
|
85
|
+
window.removeEventListener('dragover', guardFileNavigation);
|
|
86
|
+
window.removeEventListener('drop', guardFileNavigation);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UseFileDropOptions extends FileSelectionRules {
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
onSelection: (result: FileSelectionResult) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Accessible drop behavior. Consumers spread the returned handlers on their actual control. */
|
|
97
|
+
export function useFileDrop(options: UseFileDropOptions) {
|
|
98
|
+
const { disabled = false, onSelection } = options;
|
|
99
|
+
const [isDragActive, setDragActive] = useState(false);
|
|
100
|
+
const depth = useRef(0);
|
|
101
|
+
const latest = useRef(options);
|
|
102
|
+
latest.current = options;
|
|
103
|
+
|
|
104
|
+
useEffect(() => retainFileNavigationGuard(), []);
|
|
105
|
+
|
|
106
|
+
const onDragEnter = useCallback((event: DragEvent<HTMLElement>) => {
|
|
107
|
+
if (disabled || !isFileDrag(event.nativeEvent)) return;
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
depth.current += 1;
|
|
110
|
+
setDragActive(true);
|
|
111
|
+
}, [disabled]);
|
|
112
|
+
const onDragOver = useCallback((event: DragEvent<HTMLElement>) => {
|
|
113
|
+
if (disabled || !isFileDrag(event.nativeEvent)) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
116
|
+
}, [disabled]);
|
|
117
|
+
const onDragLeave = useCallback((event: DragEvent<HTMLElement>) => {
|
|
118
|
+
if (disabled || !isFileDrag(event.nativeEvent)) return;
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
121
|
+
if (depth.current === 0) setDragActive(false);
|
|
122
|
+
}, [disabled]);
|
|
123
|
+
const onDrop = useCallback((event: DragEvent<HTMLElement>) => {
|
|
124
|
+
if (disabled || !isFileDrag(event.nativeEvent)) return;
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
depth.current = 0;
|
|
127
|
+
setDragActive(false);
|
|
128
|
+
onSelection(validateFileSelection(Array.from(event.dataTransfer.files), latest.current));
|
|
129
|
+
}, [disabled, onSelection]);
|
|
130
|
+
|
|
131
|
+
return { isDragActive, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface PickerCommonProps extends FileSelectionRules {
|
|
135
|
+
disabled?: boolean;
|
|
136
|
+
multiple?: boolean;
|
|
137
|
+
onSelection: (result: FileSelectionResult) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function acceptAttribute(accept: FileSelectionRules['accept']): string | undefined {
|
|
141
|
+
const values = asAcceptList(accept);
|
|
142
|
+
return values.length ? values.join(',') : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FilePickerProps extends PickerCommonProps {
|
|
146
|
+
children?: ReactNode;
|
|
147
|
+
className?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function FilePicker({ children = 'Choose files', className, disabled, multiple = true, onSelection, ...rules }: FilePickerProps) {
|
|
151
|
+
const input = useRef<HTMLInputElement>(null);
|
|
152
|
+
const id = `file-picker-${useId().replace(/:/g, '')}`;
|
|
153
|
+
const changed = (event: ChangeEvent<HTMLInputElement>) => {
|
|
154
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
155
|
+
event.currentTarget.value = '';
|
|
156
|
+
onSelection(validateFileSelection(files, { ...rules, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1) }));
|
|
157
|
+
};
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
<input ref={input} id={id} className="file-input-hidden" type="file" tabIndex={-1} aria-hidden="true"
|
|
161
|
+
accept={acceptAttribute(rules.accept)} multiple={multiple} disabled={disabled} onChange={changed} />
|
|
162
|
+
<button type="button" className={className ? `btn file-picker ${className}` : 'btn file-picker'} disabled={disabled}
|
|
163
|
+
onClick={() => input.current?.click()}>{children}</button>
|
|
164
|
+
</>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface FileDropZoneProps extends PickerCommonProps {
|
|
169
|
+
children?: ReactNode | ((state: { isDragActive: boolean }) => ReactNode);
|
|
170
|
+
className?: string;
|
|
171
|
+
label?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function FileDropZone({ children, className, label = 'Choose files or drop them here', disabled, multiple = true, onSelection, ...rules }: FileDropZoneProps) {
|
|
175
|
+
const input = useRef<HTMLInputElement>(null);
|
|
176
|
+
const { isDragActive, dropProps } = useFileDrop({ ...rules, disabled, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1), onSelection });
|
|
177
|
+
const changed = (event: ChangeEvent<HTMLInputElement>) => {
|
|
178
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
179
|
+
event.currentTarget.value = '';
|
|
180
|
+
onSelection(validateFileSelection(files, { ...rules, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1) }));
|
|
181
|
+
};
|
|
182
|
+
return (
|
|
183
|
+
<div className="file-drop-zone-wrap">
|
|
184
|
+
<input ref={input} className="file-input-hidden" type="file" tabIndex={-1} aria-hidden="true"
|
|
185
|
+
accept={acceptAttribute(rules.accept)} multiple={multiple} disabled={disabled} onChange={changed} />
|
|
186
|
+
<button type="button" className={`file-drop-zone${isDragActive ? ' is-drag-active' : ''}${className ? ` ${className}` : ''}`}
|
|
187
|
+
disabled={disabled} aria-label={label} onClick={() => input.current?.click()} {...dropProps}>
|
|
188
|
+
{typeof children === 'function' ? children({ isDragActive }) : children ?? (
|
|
189
|
+
<span className="file-drop-prompt"><i className="bi bi-cloud-arrow-up" /> <span>{isDragActive ? 'Drop files to add them' : label}</span></span>
|
|
190
|
+
)}
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|