create-mithaq 0.1.1 → 0.2.1
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/LICENSES/openstatus-time-picker.txt +21 -0
- package/index.js +457 -63
- package/package.json +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
- package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
- package/template/apps/asas-backoffice/src/redirect.ts +1 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
- package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
- package/template/apps/asas-backoffice/src/router.tsx +18 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
- package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
- package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
- package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
- package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
- package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
- package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
- package/template/apps/asas-tanstack/package.json +4 -2
- package/template/apps/asas-tanstack/src/api.ts +1 -1
- package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +416 -79
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/links.ts +1 -1
- package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -1
- package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
- package/template/apps/asas-tanstack/src/router.tsx +12 -1
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
- package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
- package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
- package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
- package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
- package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
- package/template/apps/asas-tanstack/src/screens.tsx +46 -26
- package/template/apps/asas-tanstack/src/server.ts +10 -0
- package/template/apps/asas-tanstack/src/slide.ts +1 -1
- package/template/apps/asas-tanstack/src/styles.css +7 -3
- package/template/apps/asas-tanstack/tsconfig.json +2 -2
- package/template/apps/asas-tanstack/vite.config.ts +7 -1
- package/template/package.json +3 -3
- package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
- package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
- package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
- package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
- package/template/templates/asas/.env.example +22 -5
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1094 -0
- package/template/templates/asas/package.json +19 -4
- package/template/templates/asas/src/asas.ts +81 -56
- package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
- package/template/templates/asas/src/dev-setup.ts +112 -0
- package/template/templates/asas/src/local-database.ts +22 -0
- package/template/templates/asas/src/messages/account.json +129 -103
- package/template/templates/asas/src/messages/auditLog.json +223 -105
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +256 -214
- package/template/templates/asas/src/messages/enrollments.json +45 -27
- package/template/templates/asas/src/messages/invitation.json +72 -56
- package/template/templates/asas/src/messages/localUsers.json +34 -28
- package/template/templates/asas/src/messages/media.json +31 -21
- package/template/templates/asas/src/messages/member.json +59 -39
- package/template/templates/asas/src/messages/memberRoles.json +37 -31
- package/template/templates/asas/src/messages/mithaq.json +206 -162
- package/template/templates/asas/src/messages/organization.json +63 -41
- package/template/templates/asas/src/messages/organizationRole.json +59 -47
- package/template/templates/asas/src/messages/roles.json +59 -45
- package/template/templates/asas/src/messages/session.json +99 -79
- package/template/templates/asas/src/messages/sessions.json +110 -80
- package/template/templates/asas/src/messages/settings.json +51 -53
- package/template/templates/asas/src/messages/tags.json +31 -21
- package/template/templates/asas/src/messages/user.json +109 -87
- package/template/templates/asas/src/messages/verification.json +59 -47
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
- package/template/templates/asas/src/migrations/schema.ts +1 -0
- package/template/templates/asas/src/mithaq.ts +4 -406
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +147 -45
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
- package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
- package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
- package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +9 -13
- package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
- package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
- package/template/templates/features/audit-log/index.ts +4 -0
- package/template/templates/features/audit-log/package.json +13 -0
- package/template/templates/features/auth/index.ts +11 -0
- package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
- package/template/templates/{auth → features/auth}/package.json +2 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
- package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
- package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
- package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
- package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
- package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
- package/template/templates/features/courses/courses/cover-reads.ts +33 -0
- package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
- package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
- package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
- package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
- package/template/templates/features/courses/domain/archive.ts +39 -0
- package/template/templates/features/courses/domain/on-offer.ts +6 -0
- package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
- package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
- package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
- package/template/templates/features/courses/index.ts +8 -0
- package/template/templates/features/courses/package.json +24 -0
- package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
- package/template/templates/features/courses/portal/ui/course.tsx +176 -0
- package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
- package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
- package/template/templates/features/courses/portal/ui/reads.ts +76 -0
- package/template/templates/features/courses/portal/ui/rules.ts +87 -0
- package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
- package/template/templates/features/courses/sessions/seats.ts +42 -0
- package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
- package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
- package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
- package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
- package/template/templates/mithaq/package.json +33 -0
- package/template/templates/mithaq/src/mail/index.ts +32 -0
- package/template/templates/mithaq/src/mail/mailers.ts +36 -0
- package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
- package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
- package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
- package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
- package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
- package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
- package/template/templates/mithaq/src/mithaq.ts +593 -0
- package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
- package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
- package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
- package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
- package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
- package/template/apps/asas-platform/src/redirect.ts +0 -1
- package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
- package/template/apps/asas-platform/src/router.tsx +0 -7
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
- package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
- package/template/apps/asas-platform/src/server.ts +0 -20
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
- package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
- package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
- package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
- package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
- package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
- package/template/templates/asas/src/rich-text-nodes.ts +0 -11
- package/template/templates/asas/src/roles.ts +0 -16
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
- package/template/templates/auth/src/index.ts +0 -690
- /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
- /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
- /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
- /package/template/templates/{asas → mithaq}/components.json +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
|
@@ -1,865 +0,0 @@
|
|
|
1
|
-
import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from 'react'
|
|
2
|
-
import { ArrowDown, ArrowUp, ChevronDown, MoreHorizontal, Plus, Search as SearchIcon, X } from 'lucide-react'
|
|
3
|
-
import type { ButtonClient, ClientField, ClientKayan, Row } from 'mithaq/admin'
|
|
4
|
-
import { useScreen } from 'mithaq/admin/react'
|
|
5
|
-
import { Button, buttonVariants } from '@/components/ui/button'
|
|
6
|
-
import { Card, CardContent } from '@/components/ui/card'
|
|
7
|
-
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
8
|
-
import { Input } from '@/components/ui/input'
|
|
9
|
-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
10
|
-
import { NodeButton, OwnButton, drawsKind, isUntitled, listOf, nodesOf, pageButtonOf, refOf, rowTitle, rowWarning, scopeOf, shown as holds, type ListNode, type PageButton, type ScreenPage } from './nodes.tsx'
|
|
11
|
-
import { cellFor, cells } from './cells.tsx'
|
|
12
|
-
import { counted, descriptionOf, labelFor, operationLabelFor, refusalKeyOf, titleOf, type Copy, type FindApi, type Translate } from './controls.tsx'
|
|
13
|
-
import { DeleteButton, Form, OperationButton, type FormProps } from './form.tsx'
|
|
14
|
-
import { useRelationshipTitles } from './relationship-cell.tsx'
|
|
15
|
-
import { filterPathsOf } from './filter-operators.ts'
|
|
16
|
-
import { Filters } from './list-filters.tsx'
|
|
17
|
-
import { Columns, columnCandidates } from './list-columns.tsx'
|
|
18
|
-
import { Pagination } from './list-pagination.tsx'
|
|
19
|
-
import { SelectAll, SelectRow, writableFields } from './list-selection.tsx'
|
|
20
|
-
import {
|
|
21
|
-
canSort,
|
|
22
|
-
columnsFrom,
|
|
23
|
-
columnsTo,
|
|
24
|
-
refined,
|
|
25
|
-
remember,
|
|
26
|
-
remembered,
|
|
27
|
-
searchableFields,
|
|
28
|
-
type ColumnChoice,
|
|
29
|
-
type FilterWhere,
|
|
30
|
-
type ListQuery,
|
|
31
|
-
} from './list-query.ts'
|
|
32
|
-
|
|
33
|
-
/** One page, as the managed reader answers it. */
|
|
34
|
-
export type Page = {
|
|
35
|
-
docs: readonly Record<string, unknown>[]
|
|
36
|
-
hasNextPage: boolean
|
|
37
|
-
hasPrevPage: boolean
|
|
38
|
-
limit: number
|
|
39
|
-
nextPage: number | null
|
|
40
|
-
page: number
|
|
41
|
-
prevPage: number | null
|
|
42
|
-
totalDocs: number
|
|
43
|
-
totalPages: number
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The six options this screen drives `find` and the URL with. */
|
|
47
|
-
export type Query = ListQuery
|
|
48
|
-
|
|
49
|
-
export type ListProps = {
|
|
50
|
-
kayan: ClientKayan
|
|
51
|
-
page: Page
|
|
52
|
-
query: Query
|
|
53
|
-
t: Translate
|
|
54
|
-
locale: string
|
|
55
|
-
/**
|
|
56
|
-
* Which locales exist, from the app: a localized field is offered to the filter panel as its locale paths.
|
|
57
|
-
*/
|
|
58
|
-
locales?: readonly string[]
|
|
59
|
-
/** Every kayan of the build, so a relationship's hop can name the target's fields. */
|
|
60
|
-
client?: readonly ClientKayan[]
|
|
61
|
-
/**
|
|
62
|
-
* The typed client's reads. A relationship column names the rows it points at with one read per target for the whole page, and the filter panel's relationship value input pages its target with it.
|
|
63
|
-
*/
|
|
64
|
-
api?: FindApi
|
|
65
|
-
/**
|
|
66
|
-
* Whether this actor may read one field, off `GET /api/access`. Chrome only: the read masks a denied column and refuses a denied filter whatever this says.
|
|
67
|
-
*/
|
|
68
|
-
reads?: (kayan: string, field: string) => boolean
|
|
69
|
-
/**
|
|
70
|
-
* Whether this actor may create a row of this kayan, off the same report. Chrome only: the create is authorized server-side. Undeclared is allowed.
|
|
71
|
-
*/
|
|
72
|
-
creates?: boolean
|
|
73
|
-
/**
|
|
74
|
-
* This actor's answer per operation name, off the same report. A button in the list node's `toolbar`, `bulk` or `row` is drawn where it allows that operation, and the selection column where it allows a bulk button. Chrome only; an absent key is denied.
|
|
75
|
-
*/
|
|
76
|
-
operations?: Readonly<Record<string, boolean>>
|
|
77
|
-
/** Whether this actor may write one field on an update, for a bulk update's boxes. */
|
|
78
|
-
writes?: (field: string) => boolean
|
|
79
|
-
/**
|
|
80
|
-
* The access report a list button's `when` reads as `$user`, cut to the screen's kayan.
|
|
81
|
-
*/
|
|
82
|
-
access?: unknown
|
|
83
|
-
/**
|
|
84
|
-
* What running one of the list's buttons costs the host: the call and the re-read after it. With none, no button is drawn, as there would be nothing for it to do.
|
|
85
|
-
*/
|
|
86
|
-
run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>
|
|
87
|
-
/** Where the next query goes. Navigation, as a callback. */
|
|
88
|
-
onQuery: (query: Query) => void
|
|
89
|
-
/** Where one row goes. Navigation, as a prop, so no router is imported. */
|
|
90
|
-
href: (id: string) => string
|
|
91
|
-
/** Where a new row is written. */
|
|
92
|
-
newHref: string
|
|
93
|
-
/**
|
|
94
|
-
* A screen's list page, where this list draws one: its list node names the columns, filters and search, and the components it names by path are drawn from here.
|
|
95
|
-
*/
|
|
96
|
-
screen?: ScreenPage
|
|
97
|
-
/** The list node this list draws, where it is not the page's own: a list of another kayan. */
|
|
98
|
-
node?: ListNode
|
|
99
|
-
/**
|
|
100
|
-
* How a list of another kayan edits its rows in place, below the table: one row at a time, in the row's own form over the list's columns, a create being the same form with the page's row already in its relationship. Every write is the listed kayan's own and saves apart from the page's form. A row still opens its own page through `href`.
|
|
101
|
-
*/
|
|
102
|
-
edit?: Pick<FormProps, 'client' | 'writes'> & {
|
|
103
|
-
/** What every row it adds holds whatever the form sends: the relationship to the page's row. */
|
|
104
|
-
prefill?: Readonly<Record<string, unknown>>
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Which columns a list shows, in order: the URL's list when it carries one, else the list node's `columns`, else every field. Never a field this actor may not read.
|
|
110
|
-
*/
|
|
111
|
-
export const columnsOf = ({
|
|
112
|
-
kayan,
|
|
113
|
-
cols,
|
|
114
|
-
reads,
|
|
115
|
-
opens,
|
|
116
|
-
}: {
|
|
117
|
-
kayan: ClientKayan
|
|
118
|
-
cols?: readonly string[]
|
|
119
|
-
reads?: (kayan: string, field: string) => boolean
|
|
120
|
-
/** The columns a page's list node opens with, which is every field where there is no node. */
|
|
121
|
-
opens?: readonly string[]
|
|
122
|
-
}): readonly ColumnChoice[] => {
|
|
123
|
-
const isDefault = (_kayan: ClientKayan, field: ClientField) => !opens || opens.includes(field.name)
|
|
124
|
-
const candidates = columnCandidates(kayan, reads)
|
|
125
|
-
const offered = new Set(candidates.map((field) => field.name))
|
|
126
|
-
const fromUrl = columnsFrom(cols).filter((one) => offered.has(one.name))
|
|
127
|
-
const named = new Set(fromUrl.map((one) => one.name))
|
|
128
|
-
const rest = candidates.filter((field) => !named.has(field.name))
|
|
129
|
-
if (fromUrl.length) {
|
|
130
|
-
// A column the URL did not name keeps the declaration's answer, so a field added to a kayan is not hidden from old links.
|
|
131
|
-
return [...fromUrl, ...rest.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))]
|
|
132
|
-
}
|
|
133
|
-
return candidates.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The query a column change asks for. It keeps the page it is on, since it changes what a row shows and never which rows match.
|
|
138
|
-
*/
|
|
139
|
-
export const withColumns = (query: Query, columns: readonly ColumnChoice[]): Query => ({
|
|
140
|
-
...query,
|
|
141
|
-
cols: columnsTo(columns),
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// Pinned cells are opaque (the card's fill with the head's or hovered row's tint mixed in), because a translucent one
|
|
145
|
-
// shows the cells that scroll beneath it.
|
|
146
|
-
const headFill = 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'
|
|
147
|
-
const pinnedHead = `sticky z-10 ${headFill}`
|
|
148
|
-
const pinned = 'sticky z-10 bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'
|
|
149
|
-
// A shadow on the row menu column's start edge while the table runs on under it. The head cell keeps its rule underneath
|
|
150
|
-
// (`[&_th]:shadow-[inset_0_-1px_0_var(--border)]` on the head row), so the head's own takes `!` and carries the rule too.
|
|
151
|
-
const edge = 'shadow-[-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)] rtl:shadow-[10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]'
|
|
152
|
-
const edgeHead = 'shadow-[inset_0_-1px_0_var(--border),-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]! rtl:shadow-[inset_0_-1px_0_var(--border),10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]!'
|
|
153
|
-
// Exactly a touch target wide, so the first column pins right after it; on a phone as tall, so the box's 44 pixel layer
|
|
154
|
-
// stays inside its own row.
|
|
155
|
-
const selection = 'w-11 min-w-11 p-0 max-sm:h-11 [&>*]:mx-auto [&>[role=checkbox]]:translate-y-0'
|
|
156
|
-
|
|
157
|
-
const nextSort = (name: string, sort: string | undefined) =>
|
|
158
|
-
sort === name ? `-${name}` : sort === `-${name}` ? undefined : name
|
|
159
|
-
|
|
160
|
-
function Search({
|
|
161
|
-
fields,
|
|
162
|
-
locale,
|
|
163
|
-
onSearch,
|
|
164
|
-
t,
|
|
165
|
-
value,
|
|
166
|
-
}: {
|
|
167
|
-
value: string | undefined
|
|
168
|
-
/** The labels of the fields a search runs over, which is empty for a kayan with none. */
|
|
169
|
-
fields: readonly string[]
|
|
170
|
-
locale: string
|
|
171
|
-
onSearch: (term: string | undefined) => void
|
|
172
|
-
t: Translate
|
|
173
|
-
}): ReactElement | null {
|
|
174
|
-
const [text, setText] = useState(value ?? '')
|
|
175
|
-
useEffect(() => setText(value ?? ''), [value])
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (text === (value ?? '')) return
|
|
178
|
-
const timer = setTimeout(() => onSearch(text.trim() === '' ? undefined : text), 300)
|
|
179
|
-
return () => clearTimeout(timer)
|
|
180
|
-
}, [onSearch, text, value])
|
|
181
|
-
// No searchable field, no box. After the hooks, which must run on every render or on none.
|
|
182
|
-
if (!fields.length) return null
|
|
183
|
-
const label = t('admin.search', 'Search') ?? 'Search'
|
|
184
|
-
const placeholder = new Intl.ListFormat(locale, { type: 'disjunction' }).format(fields)
|
|
185
|
-
return (
|
|
186
|
-
<span className="flex w-full items-center gap-1 lg:max-w-sm">
|
|
187
|
-
<span className="relative w-full">
|
|
188
|
-
<SearchIcon className="text-muted-foreground absolute start-3 top-1/2 size-4 -translate-y-1/2" aria-hidden="true" />
|
|
189
|
-
<Input
|
|
190
|
-
type="search"
|
|
191
|
-
className="ps-9 max-sm:h-11"
|
|
192
|
-
placeholder={placeholder}
|
|
193
|
-
aria-label={label}
|
|
194
|
-
value={text}
|
|
195
|
-
onChange={(event) => setText(event.target.value)}
|
|
196
|
-
/>
|
|
197
|
-
</span>
|
|
198
|
-
{text ? (
|
|
199
|
-
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" onClick={() => setText('')} aria-label={t('admin.clearSearch', 'Clear search') ?? 'Clear search'}>
|
|
200
|
-
<X />
|
|
201
|
-
</Button>
|
|
202
|
-
) : null}
|
|
203
|
-
</span>
|
|
204
|
-
)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function List({
|
|
208
|
-
access,
|
|
209
|
-
api,
|
|
210
|
-
client,
|
|
211
|
-
creates,
|
|
212
|
-
edit,
|
|
213
|
-
href,
|
|
214
|
-
kayan,
|
|
215
|
-
locale,
|
|
216
|
-
locales,
|
|
217
|
-
newHref,
|
|
218
|
-
onQuery,
|
|
219
|
-
operations,
|
|
220
|
-
page,
|
|
221
|
-
query,
|
|
222
|
-
reads,
|
|
223
|
-
run,
|
|
224
|
-
screen,
|
|
225
|
-
t,
|
|
226
|
-
writes,
|
|
227
|
-
...props
|
|
228
|
-
}: ListProps): ReactElement {
|
|
229
|
-
const copy: Copy = { t, locale }
|
|
230
|
-
const { maxLimit } = useScreen()
|
|
231
|
-
const node = props.node ?? listOf(screen)
|
|
232
|
-
/** The row being edited in place, `null` for a row being added, and nothing while no form is open. */
|
|
233
|
-
const [editing, setEditing] = useState<Row | null | undefined>(undefined)
|
|
234
|
-
const edits = edit && node
|
|
235
|
-
? { ...edit, client: { ...edit.client, create: (input: Record<string, unknown>) => edit.client.create({ ...input, ...edit.prefill }) } }
|
|
236
|
-
: undefined
|
|
237
|
-
const Heading = node?.of ? 'h2' : 'h1'
|
|
238
|
-
const Empty = refOf({ screen, place: 'empty', path: node?.empty })
|
|
239
|
-
/** The project's items in each row's menu, and the dialog one of them opened, which outlives the menu. */
|
|
240
|
-
const Menu = refOf({ screen, place: 'menu', path: node?.menu })
|
|
241
|
-
const [over, setOver] = useState<ReactNode>(null)
|
|
242
|
-
const cellOf = new Map(node?.columns.map((column) => [column.field, column.cell]))
|
|
243
|
-
const searched = searchableFields({ kayan, search: node?.search, client })
|
|
244
|
-
const columns = columnsOf({ kayan, cols: query.cols, reads, opens: node?.columns.map((column) => column.field) })
|
|
245
|
-
const active = columns.flatMap((column) => kayan.fields.find((field) => field.name === column.name && column.active) ?? [])
|
|
246
|
-
const titled = active.find((field) => field.name === kayan.useAsTitle)
|
|
247
|
-
const shown = titled ? [titled, ...active.filter((field) => field !== titled)] : active
|
|
248
|
-
// One read per target for the whole page, so a relationship column is not one read per cell. Over the shown columns
|
|
249
|
-
// alone, so a relationship the viewer has switched off costs no read.
|
|
250
|
-
const titles = useRelationshipTitles({ fields: shown, docs: page.docs, context: { api, client, locale } })
|
|
251
|
-
const paths = filterPathsOf(kayan, { client, locales, reads, ...(node?.filters && { filters: node.filters }) })
|
|
252
|
-
// The first shown cell carries the row's link, so a hidden or unreadable title leaves a row reachable.
|
|
253
|
-
const linked = shown[0]
|
|
254
|
-
const refine = (next: Partial<Query>) => onQuery(refined(query, next))
|
|
255
|
-
const filtered = Boolean(query.where || query.search)
|
|
256
|
-
/**
|
|
257
|
-
* Whether the person has opened or closed the filter panel, and nothing until they do. Until then it is open on a list whose URL filters, read on every render so a `where` that arrives after mount opens it.
|
|
258
|
-
*/
|
|
259
|
-
const [filtering, setFiltering] = useState<boolean | undefined>(undefined)
|
|
260
|
-
const open = filtering ?? Boolean(query.where)
|
|
261
|
-
const filtersId = useId()
|
|
262
|
-
/** The kayan's plural, which the bulk dialog and its result name the ticked rows by. */
|
|
263
|
-
const plural = titleOf({ kayan, copy, many: true })
|
|
264
|
-
const about = descriptionOf(kayan, copy)
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* The list node's buttons this actor may run, drawn only where the host said what running one costs and the report allows its operation by name.
|
|
268
|
-
*/
|
|
269
|
-
const allowed = (declared: readonly ButtonClient[] | undefined) =>
|
|
270
|
-
run ? (declared ?? []).flatMap((node) => {
|
|
271
|
-
const operation = pageButtonOf({ kayan, node, allowed: operations })
|
|
272
|
-
return operation ? [{ node, operation }] : []
|
|
273
|
-
}) : []
|
|
274
|
-
/** What a toolbar or bulk button's `when` reads: no values and no row, and the report as `$user`. */
|
|
275
|
-
const scope = scopeOf({ values: {}, record: null, access, kayan })
|
|
276
|
-
const toolbar = allowed(node?.toolbar).filter((one) => holds(one.node, scope))
|
|
277
|
-
// A bulk button's boxes are the row's fields it names, cut to those this actor could write; its call is the ticked
|
|
278
|
-
// rows' `where` around them.
|
|
279
|
-
const bulkButtons = allowed(node?.bulk).filter((one) => holds(one.node, scope)).map((one) => ({
|
|
280
|
-
...one,
|
|
281
|
-
operation: { ...one.operation, fields: writableFields(kayan, writes).filter((field) => one.node.fields?.includes(field.name)) },
|
|
282
|
-
}))
|
|
283
|
-
const rowButtons = allowed(node?.row)
|
|
284
|
-
const editsRows = edits !== undefined && operations?.update === true
|
|
285
|
-
const deletes = run !== undefined && operations?.delete === true && kayan.operations.some((one) => one.name === 'delete')
|
|
286
|
-
const [deleting, setDeleting] = useState<Row | undefined>(undefined)
|
|
287
|
-
const [asking, setAsking] = useState<{ row: Row; operation: PageButton } | undefined>(undefined)
|
|
288
|
-
const [running, setRunning] = useState(false)
|
|
289
|
-
const table = useRef<HTMLTableElement>(null)
|
|
290
|
-
// Whether the table runs past its frame's end, so the frame fades there. The value is the width of the row menu's pinned
|
|
291
|
-
// column, which the fade sits before, and `false` where nothing runs past. `scrollLeft` is negative on a right-to-left page.
|
|
292
|
-
const [more, setMore] = useState<number | false>(false)
|
|
293
|
-
useEffect(() => {
|
|
294
|
-
const grid = table.current
|
|
295
|
-
const frame = grid?.parentElement
|
|
296
|
-
if (!grid || !frame) return
|
|
297
|
-
const check = () => setMore(frame.scrollWidth - frame.clientWidth - Math.abs(frame.scrollLeft) > 1 && (grid.querySelector('thead th:last-child')?.getBoundingClientRect().width ?? 0))
|
|
298
|
-
check()
|
|
299
|
-
frame.addEventListener('scroll', check, { passive: true })
|
|
300
|
-
const resized = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(check)
|
|
301
|
-
resized?.observe(frame)
|
|
302
|
-
resized?.observe(grid)
|
|
303
|
-
return () => {
|
|
304
|
-
frame.removeEventListener('scroll', check)
|
|
305
|
-
resized?.disconnect()
|
|
306
|
-
}
|
|
307
|
-
}, [])
|
|
308
|
-
/**
|
|
309
|
-
* The name of a row's menu button, naming the row by its title so a reader going down the column hears each row's own; a row with no title, or with the title column hidden, keeps the shared name.
|
|
310
|
-
*/
|
|
311
|
-
const rowActionsLabel = (row: Row) => {
|
|
312
|
-
const name = titled && rowTitle({ kayan, row, locale })
|
|
313
|
-
// Bidi-isolated, so a name in the other direction does not reorder the sentence.
|
|
314
|
-
return name ? (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `\u2068${name}\u2069`) : (t('admin.rowActions', 'Row actions') ?? 'Row actions')
|
|
315
|
-
}
|
|
316
|
-
const openHint = useId()
|
|
317
|
-
/**
|
|
318
|
-
* Opens a row by clicking its own link, so the host routes it as it routes every other link and the list imports no router.
|
|
319
|
-
*/
|
|
320
|
-
const openRow = (id: string) =>
|
|
321
|
-
[...(table.current?.querySelectorAll<HTMLAnchorElement>('a[data-row-link]') ?? [])].find((one) => one.dataset.rowLink === id)?.click()
|
|
322
|
-
const runOnRow = async (operation: string, input: Record<string, unknown>, refuse: (thrown: unknown) => void = setRefused) => {
|
|
323
|
-
setRunning(true)
|
|
324
|
-
setRefused(undefined)
|
|
325
|
-
try {
|
|
326
|
-
await run!(operation, input)
|
|
327
|
-
return true
|
|
328
|
-
} catch (thrown) {
|
|
329
|
-
refuse(thrown)
|
|
330
|
-
return false
|
|
331
|
-
} finally {
|
|
332
|
-
setRunning(false)
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Whether no column the list opens with is one the viewer may read, which the projection says by leaving the field out. Such a list draws no rows, nor anything that acts on them.
|
|
337
|
-
*/
|
|
338
|
-
const unreadable = !(node?.columns.map((column) => column.field) ?? kayan.fields.map((field) => field.name)).some((name) => kayan.fields.some((field) => field.name === name))
|
|
339
|
-
const picks = bulkButtons.length > 0 && !unreadable
|
|
340
|
-
/**
|
|
341
|
-
* The refusal the last run answered with, drawn above the table, so a refused button that closed does not leave the page reading as changed.
|
|
342
|
-
*/
|
|
343
|
-
const [refused, setRefused] = useState<unknown>(undefined)
|
|
344
|
-
const refusal = refusalKeyOf(refused)
|
|
345
|
-
const button = (one: PageButton, inputOf: (values: Record<string, unknown>) => Record<string, unknown>, key: string, extra: { description?: string; disabled?: boolean; onRan?: (written: unknown) => void } = {}) => (
|
|
346
|
-
<ListButton key={key} operation={one} kayan={kayan} client={client} api={api} copy={copy} screen={screen} run={run!} inputOf={inputOf} onRefused={setRefused} {...extra} />
|
|
347
|
-
)
|
|
348
|
-
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set())
|
|
349
|
-
/**
|
|
350
|
-
* How many rows the last bulk changed, announced in the count's live region after the ticks drop. A new object per bulk restarts its six seconds; it clears after them or when a row is ticked again.
|
|
351
|
-
*/
|
|
352
|
-
const [done, setDone] = useState<{ count: number } | undefined>(undefined)
|
|
353
|
-
useEffect(() => {
|
|
354
|
-
if (!done) return
|
|
355
|
-
const timer = setTimeout(() => setDone(undefined), 6000)
|
|
356
|
-
return () => clearTimeout(timer)
|
|
357
|
-
}, [done])
|
|
358
|
-
const rowIds = page.docs.map((row) => String(row.id ?? ''))
|
|
359
|
-
/**
|
|
360
|
-
* The ticked rows still on the page. A page that moved under the selection leaves ids that are no longer on screen, and a delete of rows nobody can see is the one thing this must not send.
|
|
361
|
-
*/
|
|
362
|
-
const ticked = rowIds.filter((one) => one && selected.has(one))
|
|
363
|
-
useEffect(() => {
|
|
364
|
-
if (ticked.length) setDone(undefined)
|
|
365
|
-
}, [ticked.length])
|
|
366
|
-
/** A counted sentence about the ticked rows, capitalised though the kayan's plural may be lower case. */
|
|
367
|
-
const aboutTicked = (key: string, count: number, fallback: string) => {
|
|
368
|
-
const said = counted({ t, locale, key, count, fallback }).replace('{plural}', () => plural)
|
|
369
|
-
return said.charAt(0).toLocaleUpperCase(locale) + said.slice(1)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Per-viewer limit and columns are remembered in `localStorage`, read once and only where the URL named neither.
|
|
373
|
-
// Read in an effect: it does not exist on the server, and reading it during render would mismatch hydration.
|
|
374
|
-
useEffect(() => {
|
|
375
|
-
if (query.limit !== undefined || query.cols !== undefined) return
|
|
376
|
-
const held = remembered(kayan.name, maxLimit)
|
|
377
|
-
if (held.limit === undefined && held.cols === undefined) return
|
|
378
|
-
onQuery({ ...query, ...held })
|
|
379
|
-
// The kayan alone: running again on the query would undo a viewer's own change back to what was remembered.
|
|
380
|
-
}, [kayan.name])
|
|
381
|
-
useEffect(() => {
|
|
382
|
-
if (query.limit === undefined && query.cols === undefined) return
|
|
383
|
-
remember(kayan.name, { ...(query.limit !== undefined && { limit: query.limit }), ...(query.cols !== undefined && { cols: query.cols }) })
|
|
384
|
-
}, [kayan.name, query.cols, query.limit])
|
|
385
|
-
|
|
386
|
-
return (
|
|
387
|
-
<section className={`flex w-full min-w-0 flex-1 flex-col gap-4 ${ticked.length || done ? 'max-sm:pb-[calc(5rem+env(safe-area-inset-bottom))]' : ''}`}>
|
|
388
|
-
{/* `flex-wrap`: on a phone the title and the button share 320 pixels. `min-w-0` on the section lets the list shrink
|
|
389
|
-
inside the grid or flex row that holds it, whose items otherwise keep the width of their widest row. */}
|
|
390
|
-
<header className="flex flex-wrap items-start justify-between gap-4">
|
|
391
|
-
<div className="grid min-w-0 gap-1">
|
|
392
|
-
{/* A title node on the screen's own list page is the page's one h1, so the list keeps only its sentence; a list
|
|
393
|
-
of another kayan keeps its heading. */}
|
|
394
|
-
{screen && !node?.of && drawsKind({ nodes: nodesOf(screen.page), kind: 'title', scope }) ? null : <Heading className={node?.of ? 'text-lg font-semibold' : 'text-2xl font-bold tracking-tight'}>{titleOf({ kayan, copy, many: true })}</Heading>}
|
|
395
|
-
{about ? <p className="text-muted-foreground text-sm">{about}</p> : null}
|
|
396
|
-
</div>
|
|
397
|
-
{creates === false ? null : edits ? (
|
|
398
|
-
// A list of another kayan adds a row in place, below the table.
|
|
399
|
-
<Button type="button" className="max-sm:h-11" onClick={() => setEditing(null)}>
|
|
400
|
-
<Plus className="me-2 h-4 w-4" />
|
|
401
|
-
{t(`${kayan.name}.new`, t('admin.new', 'New'))}
|
|
402
|
-
</Button>
|
|
403
|
-
) : (
|
|
404
|
-
// `render` keeps the destination an anchor; `nativeButton={false}` tells base-ui the rendered element is not
|
|
405
|
-
// a button.
|
|
406
|
-
<Button nativeButton={false} className="max-sm:h-11" render={<a href={newHref} />}>
|
|
407
|
-
<Plus className="me-2 h-4 w-4" />
|
|
408
|
-
{t(`${kayan.name}.new`, t('admin.new', 'New'))}
|
|
409
|
-
</Button>
|
|
410
|
-
)}
|
|
411
|
-
</header>
|
|
412
|
-
|
|
413
|
-
<Card>
|
|
414
|
-
<CardContent>
|
|
415
|
-
<div className="w-full space-y-4">
|
|
416
|
-
<div className="flex flex-col justify-between gap-2 lg:flex-row lg:items-center">
|
|
417
|
-
<div className="flex flex-1 flex-col gap-2 lg:flex-row lg:items-center">
|
|
418
|
-
<Search
|
|
419
|
-
value={query.search}
|
|
420
|
-
fields={searched.flatMap((path) => {
|
|
421
|
-
// A path across a relationship is named by both labels ("Course Title"), so the far field is not
|
|
422
|
-
// mistaken for the list's own, in the locale's order.
|
|
423
|
-
const [own, far] = path.split('.')
|
|
424
|
-
const near = kayan.fields.find((one) => one.name === own)
|
|
425
|
-
const there = far === undefined ? kayan : client?.find((one) => one.name === near?.relationTo)
|
|
426
|
-
const field = there?.fields.find((one) => one.name === (far ?? own))
|
|
427
|
-
if (!there || !field) return []
|
|
428
|
-
const label = labelFor({ kayan: there, field, copy })
|
|
429
|
-
if (far === undefined || !near) return [label]
|
|
430
|
-
return [(t('admin.searchThrough', '{relation} {field}') ?? '{relation} {field}').replace('{relation}', () => labelFor({ kayan, field: near, copy })).replace('{field}', () => label)]
|
|
431
|
-
})}
|
|
432
|
-
locale={locale}
|
|
433
|
-
t={t}
|
|
434
|
-
onSearch={(search) => refine({ search })}
|
|
435
|
-
/>
|
|
436
|
-
</div>
|
|
437
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
438
|
-
{refusal ? (
|
|
439
|
-
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
440
|
-
{t(refusal, refusal)}
|
|
441
|
-
</p>
|
|
442
|
-
) : null}
|
|
443
|
-
{toolbar.map((one, at) => button(one.operation, (values) => values, `toolbar-${at}`, { description: t('admin.toolbarWarning', 'This runs on no row of the list.') ?? 'This runs on no row of the list.' }))}
|
|
444
|
-
{/* Both stay mounted, the buttons hidden until a row is ticked: a live region must exist before its first change
|
|
445
|
-
to be announced, and a dialog a button opened must outlive a read that took the ticked rows off the page. */}
|
|
446
|
-
{picks ? (
|
|
447
|
-
// On a phone the count and bulk buttons are a bar fixed to the bottom while a row is ticked, so ticking
|
|
448
|
-
// does not push the table down under the finger. It stands on the form's own bar where a record page draws one.
|
|
449
|
-
<div
|
|
450
|
-
data-mithaq-bulk-bar=""
|
|
451
|
-
role="region"
|
|
452
|
-
aria-label={t('admin.bulkBar', 'Ticked rows') ?? 'Ticked rows'}
|
|
453
|
-
className={ticked.length || done ? 'contents max-sm:bg-background max-sm:fixed max-sm:inset-x-0 max-sm:bottom-[var(--action-bar-height,0px)] max-sm:z-30 max-sm:flex max-sm:flex-nowrap max-sm:items-center max-sm:gap-2 max-sm:overflow-x-auto max-sm:border-t max-sm:p-3 max-sm:pb-[max(0.75rem,env(safe-area-inset-bottom))] max-sm:[&_button]:shrink-0 max-sm:[&>span]:shrink-0' : 'contents'}
|
|
454
|
-
>
|
|
455
|
-
{/* `aria-live`: the count changes as boxes are ticked and nothing else announces it. */}
|
|
456
|
-
<span className={ticked.length || done ? 'text-muted-foreground text-sm' : 'sr-only'} aria-live="polite" data-mithaq-selected={ticked.length || undefined} data-mithaq-done={!ticked.length && done ? done.count : undefined}>
|
|
457
|
-
{ticked.length
|
|
458
|
-
? (t('admin.selected', '{count} selected') ?? '{count} selected').replace('{count}', String(ticked.length))
|
|
459
|
-
: done ? aboutTicked('admin.bulkDone', done.count, '{plural} done: {count}.') : ''}
|
|
460
|
-
</span>
|
|
461
|
-
<span hidden={ticked.length === 0} className={ticked.length ? 'contents' : undefined}>
|
|
462
|
-
{bulkButtons.map((one, at) =>
|
|
463
|
-
button(
|
|
464
|
-
one.operation,
|
|
465
|
-
// Only the ticked rows still on the page, with the boxes' values as the data written over each.
|
|
466
|
-
(values) => ({ where: { id: { in: ticked } }, ...(one.operation.fields.length > 0 && { data: values }) }),
|
|
467
|
-
`bulk-${at}`,
|
|
468
|
-
{
|
|
469
|
-
// A where of no id is a call nobody ticked, which a dialog left open over rows that left the
|
|
470
|
-
// page would confirm, so it is disabled.
|
|
471
|
-
disabled: ticked.length === 0,
|
|
472
|
-
description: aboutTicked('admin.bulkWarning', ticked.length, '{plural} you ticked: {count}. This applies to each of them.'),
|
|
473
|
-
// The ticked ids name rows the host's re-read is about to drop, so the selection is cleared
|
|
474
|
-
// and what the bulk did is said, less the rows it named as left.
|
|
475
|
-
onRan: (written) => {
|
|
476
|
-
setDone({ count: ticked.length - ((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0) })
|
|
477
|
-
setSelected(new Set())
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
),
|
|
481
|
-
)}
|
|
482
|
-
</span>
|
|
483
|
-
</div>
|
|
484
|
-
) : null}
|
|
485
|
-
{paths.length ? (
|
|
486
|
-
<Button
|
|
487
|
-
type="button"
|
|
488
|
-
variant={open || query.where ? 'secondary' : 'outline'}
|
|
489
|
-
className="max-sm:h-11"
|
|
490
|
-
aria-controls={open ? filtersId : undefined}
|
|
491
|
-
aria-expanded={open}
|
|
492
|
-
onClick={() => setFiltering(!open)}
|
|
493
|
-
>
|
|
494
|
-
{t('admin.filters', 'Filters') ?? 'Filters'}
|
|
495
|
-
<ChevronDown className={open ? 'rotate-180' : undefined} aria-hidden="true" />
|
|
496
|
-
</Button>
|
|
497
|
-
) : null}
|
|
498
|
-
{unreadable ? null : (
|
|
499
|
-
<Columns
|
|
500
|
-
kayan={kayan}
|
|
501
|
-
columns={columns}
|
|
502
|
-
t={t}
|
|
503
|
-
locale={locale}
|
|
504
|
-
onChange={(next) => onQuery(withColumns(query, next))}
|
|
505
|
-
/>
|
|
506
|
-
)}
|
|
507
|
-
</div>
|
|
508
|
-
</div>
|
|
509
|
-
|
|
510
|
-
{open ? (
|
|
511
|
-
<div id={filtersId}>
|
|
512
|
-
<Filters
|
|
513
|
-
paths={paths}
|
|
514
|
-
value={query.where}
|
|
515
|
-
t={t}
|
|
516
|
-
locale={locale}
|
|
517
|
-
client={client}
|
|
518
|
-
api={api}
|
|
519
|
-
onChange={(where: FilterWhere | undefined) => {
|
|
520
|
-
// The person is at work in the panel, so it stays, even where the last filter goes.
|
|
521
|
-
setFiltering(true)
|
|
522
|
-
refine({ where })
|
|
523
|
-
}}
|
|
524
|
-
/>
|
|
525
|
-
</div>
|
|
526
|
-
) : null}
|
|
527
|
-
|
|
528
|
-
{/* Held to the screen below the page's header so it scrolls both ways and the head row can stay (a printed page
|
|
529
|
-
holds every row). It isolates, so pinned cells never paint over the page's sticky header or take its clicks. */}
|
|
530
|
-
<div className="bg-card relative isolate overflow-hidden rounded-lg border [&>div:first-child]:max-h-[calc(100dvh-var(--header-height,0px))] print:[&>div:first-child]:max-h-none">
|
|
531
|
-
{unreadable ? null : (
|
|
532
|
-
<Table ref={table}>
|
|
533
|
-
{/* The head rule is each head cell's own inset shadow, since a collapsed border scrolls away from a sticky
|
|
534
|
-
head; the row's own `border-b` is dropped so both do not draw. */}
|
|
535
|
-
<TableHeader className={`sticky top-0 z-20 ${headFill} [&_tr]:border-b-0 [&_th]:shadow-[inset_0_-1px_0_var(--border)]`}>
|
|
536
|
-
<TableRow>
|
|
537
|
-
{picks ? (
|
|
538
|
-
<TableHead scope="col" className={`${pinnedHead} start-0 ${selection}`}>
|
|
539
|
-
<SelectAll ids={rowIds} selected={selected} onSelect={setSelected} t={t} />
|
|
540
|
-
</TableHead>
|
|
541
|
-
) : null}
|
|
542
|
-
{shown.map((field) => {
|
|
543
|
-
// A heading is a button only where the read would take the sort: the server answers 400 for a
|
|
544
|
-
// localized field, a set, a join and a screen's `sortable: false`.
|
|
545
|
-
const sortable = canSort(field)
|
|
546
|
-
// The URL's sort, else the list node's default, which is the order the page was read in.
|
|
547
|
-
const sort = query.sort ?? node?.sort
|
|
548
|
-
const direction = sort === field.name ? 'ascending' : sort === `-${field.name}` ? 'descending' : 'none'
|
|
549
|
-
return (
|
|
550
|
-
<TableHead key={field.name} scope="col" aria-sort={sortable ? direction : undefined} className={field === linked ? `${pinnedHead} ${picks ? 'start-11' : 'start-0'}` : undefined}>
|
|
551
|
-
{sortable ? (
|
|
552
|
-
// One arrow, on the sorted column only. `aria-hidden` on the glyph and `aria-sort` on the
|
|
553
|
-
// cell keep the answer readable without it.
|
|
554
|
-
<Button
|
|
555
|
-
type="button"
|
|
556
|
-
variant="ghost"
|
|
557
|
-
size="sm"
|
|
558
|
-
className="-ms-2 h-8 px-2 max-sm:h-11 max-sm:min-w-11"
|
|
559
|
-
onClick={() => refine({ sort: nextSort(field.name, query.sort) })}
|
|
560
|
-
>
|
|
561
|
-
{labelFor({ kayan, field, copy })}
|
|
562
|
-
{direction === 'ascending' ? (
|
|
563
|
-
<ArrowUp className="size-4" aria-hidden="true" />
|
|
564
|
-
) : direction === 'descending' ? (
|
|
565
|
-
<ArrowDown className="size-4" aria-hidden="true" />
|
|
566
|
-
) : null}
|
|
567
|
-
</Button>
|
|
568
|
-
) : (
|
|
569
|
-
labelFor({ kayan, field, copy })
|
|
570
|
-
)}
|
|
571
|
-
</TableHead>
|
|
572
|
-
)
|
|
573
|
-
})}
|
|
574
|
-
<TableHead scope="col" className={`${pinnedHead} end-0 w-0 ${more === false ? '' : edgeHead}`}>
|
|
575
|
-
<span className="sr-only">{t('admin.actions', 'Actions')}</span>
|
|
576
|
-
</TableHead>
|
|
577
|
-
</TableRow>
|
|
578
|
-
</TableHeader>
|
|
579
|
-
<TableBody>
|
|
580
|
-
{page.docs.map((row, index) => {
|
|
581
|
-
const id = String(row.id ?? '')
|
|
582
|
-
// The list node's row buttons whose `when` holds over this row. One a project draws with its own
|
|
583
|
-
// component stays in the cell, since a menu item cannot be one.
|
|
584
|
-
const offered = rowButtons.filter((one) => holds(one.node, scopeOf({ values: row, record: row, access, kayan })))
|
|
585
|
-
const own = offered.filter((one) => refOf({ screen, place: 'button', path: one.operation.component }))
|
|
586
|
-
const menu = offered.filter((one) => !own.includes(one))
|
|
587
|
-
// `#` is the host's word for a kayan with no page that draws a row, so such a row is no link and
|
|
588
|
-
// no tab stop, and its menu offers neither Edit nor View.
|
|
589
|
-
const to = href(id)
|
|
590
|
-
const opens = to !== '#'
|
|
591
|
-
const edits = editsRows || opens
|
|
592
|
-
return (
|
|
593
|
-
// The whole row opens the record, and so does Enter on the row. A click on anything inside that acts
|
|
594
|
-
// on its own, or on the menu or a dialog it opened (React hands those up through its portal), opens nothing.
|
|
595
|
-
<TableRow
|
|
596
|
-
key={String(row.id ?? index)}
|
|
597
|
-
tabIndex={opens ? 0 : undefined}
|
|
598
|
-
data-row={id}
|
|
599
|
-
aria-describedby={opens ? openHint : undefined}
|
|
600
|
-
// The ring every focusable control draws, inset so the table's frame does not clip it.
|
|
601
|
-
className={opens ? 'group/row cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset' : 'group/row'}
|
|
602
|
-
onClick={(event: MouseEvent<HTMLTableRowElement>) => {
|
|
603
|
-
const target = event.target as Element
|
|
604
|
-
if (!opens || !event.currentTarget.contains(target) || target.closest('a, button, input, select, textarea, label, [role="checkbox"], [role="menuitem"]')) return
|
|
605
|
-
openRow(id)
|
|
606
|
-
}}
|
|
607
|
-
onKeyDown={(event: KeyboardEvent<HTMLTableRowElement>) => {
|
|
608
|
-
if (!opens || event.key !== 'Enter' || event.target !== event.currentTarget) return
|
|
609
|
-
event.preventDefault()
|
|
610
|
-
openRow(id)
|
|
611
|
-
}}
|
|
612
|
-
>
|
|
613
|
-
{picks ? (
|
|
614
|
-
<TableCell className={`${pinned} start-0 ${selection}`}>
|
|
615
|
-
<SelectRow id={id} selected={selected} onSelect={setSelected} t={t} />
|
|
616
|
-
</TableCell>
|
|
617
|
-
) : null}
|
|
618
|
-
{shown.map((field) => {
|
|
619
|
-
const Ref = refOf({ screen, place: 'cell', path: cellOf.get(field.name) ?? field.cell })
|
|
620
|
-
// An email cell is a mail link, and a link cannot sit in the row's link (`<a>` in `<a>` is a
|
|
621
|
-
// hydration error), so the linked column draws its text. Upload draws no anchor.
|
|
622
|
-
const drawn = cellFor(field, cellOf.get(field.name))
|
|
623
|
-
const Cell = drawn === cells.email && field === linked && opens ? cells.text! : drawn
|
|
624
|
-
const untitled = field.name === kayan.useAsTitle && isUntitled({ kayan, row, locale })
|
|
625
|
-
const cell = untitled ? (
|
|
626
|
-
<span className="text-muted-foreground">{t('admin.untitled', 'Untitled') ?? 'Untitled'}</span>
|
|
627
|
-
) : Ref && screen ? (
|
|
628
|
-
<Ref field={field} value={row[field.name]} row={row} />
|
|
629
|
-
) : (
|
|
630
|
-
<Cell kayan={kayan} field={field} value={row[field.name]} t={t} locale={locale} titles={titles} />
|
|
631
|
-
)
|
|
632
|
-
return (
|
|
633
|
-
<TableCell key={field.name} className={field === linked ? `${pinned} ${picks ? 'start-11' : 'start-0'}` : undefined}>
|
|
634
|
-
{field === linked && opens ? (
|
|
635
|
-
// A real link, so it opens in a new tab and lists for a reader, but out of the tab
|
|
636
|
-
// order since the row is the stop. An untitled row's name holds the visible text.
|
|
637
|
-
<a href={to} data-row-link={id} tabIndex={-1} aria-label={untitled ? (t('admin.openUntitled', 'Untitled, open') ?? 'Untitled, open') : undefined} className="font-medium hover:underline">
|
|
638
|
-
{cell}
|
|
639
|
-
</a>
|
|
640
|
-
) : (
|
|
641
|
-
cell
|
|
642
|
-
)}
|
|
643
|
-
</TableCell>
|
|
644
|
-
)
|
|
645
|
-
})}
|
|
646
|
-
<TableCell className={`${pinned} end-0 w-0 ${more === false ? '' : edge}`}>
|
|
647
|
-
<div className="flex items-center justify-end gap-2">
|
|
648
|
-
{own.map((one, at) => button(one.operation, (values) => ({ ...values, id: row.id }), `row-${at}`, { description: rowWarning({ kayan, row, operation: one.operation.name, copy }) }))}
|
|
649
|
-
{edits || menu.length || deletes || Menu ? (
|
|
650
|
-
<DropdownMenu>
|
|
651
|
-
<DropdownMenuTrigger
|
|
652
|
-
data-row-menu={id}
|
|
653
|
-
aria-label={rowActionsLabel(row)}
|
|
654
|
-
className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}
|
|
655
|
-
>
|
|
656
|
-
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
657
|
-
</DropdownMenuTrigger>
|
|
658
|
-
<DropdownMenuContent align="end" className="w-40">
|
|
659
|
-
{/* View where the report lets this actor update no row of the kayan, since the row's
|
|
660
|
-
page is then only read. */}
|
|
661
|
-
{edits ? (
|
|
662
|
-
operations && operations.update !== true ? (
|
|
663
|
-
<DropdownMenuItem data-row-action="view" className="max-sm:min-h-11" onClick={() => openRow(id)}>
|
|
664
|
-
{t('admin.view', 'View') ?? 'View'}
|
|
665
|
-
</DropdownMenuItem>
|
|
666
|
-
) : (
|
|
667
|
-
<DropdownMenuItem data-row-action="edit" className="max-sm:min-h-11" onClick={() => (editsRows ? setEditing(row) : openRow(id))}>
|
|
668
|
-
{t('admin.edit', 'Edit') ?? 'Edit'}
|
|
669
|
-
</DropdownMenuItem>
|
|
670
|
-
)
|
|
671
|
-
) : null}
|
|
672
|
-
{menu.map((one) => (
|
|
673
|
-
<DropdownMenuItem
|
|
674
|
-
key={one.operation.name}
|
|
675
|
-
data-row-action={one.operation.name}
|
|
676
|
-
className="max-sm:min-h-11"
|
|
677
|
-
disabled={running}
|
|
678
|
-
onClick={() => {
|
|
679
|
-
// A button that asks for values or carries a confirm opens its dialog over the
|
|
680
|
-
// row; any other runs.
|
|
681
|
-
if (one.operation.fields?.length || one.operation.confirm) return setAsking({ row, operation: one.operation })
|
|
682
|
-
void runOnRow(one.operation.name, { id: row.id })
|
|
683
|
-
}}
|
|
684
|
-
>
|
|
685
|
-
{operationLabelFor({ kayan, operation: one.operation, copy })}
|
|
686
|
-
</DropdownMenuItem>
|
|
687
|
-
))}
|
|
688
|
-
{Menu ? <Menu row={row} open={setOver} /> : null}
|
|
689
|
-
{deletes ? (
|
|
690
|
-
<DropdownMenuItem data-row-action="delete" variant="destructive" className="max-sm:min-h-11" disabled={running} onClick={() => setDeleting(row)}>
|
|
691
|
-
{t('admin.delete', 'Delete') ?? 'Delete'}
|
|
692
|
-
</DropdownMenuItem>
|
|
693
|
-
) : null}
|
|
694
|
-
</DropdownMenuContent>
|
|
695
|
-
</DropdownMenu>
|
|
696
|
-
) : null}
|
|
697
|
-
</div>
|
|
698
|
-
</TableCell>
|
|
699
|
-
</TableRow>
|
|
700
|
-
)
|
|
701
|
-
})}
|
|
702
|
-
</TableBody>
|
|
703
|
-
</Table>
|
|
704
|
-
)}
|
|
705
|
-
{/* The table can be wider than a phone, so a sentence in a cell spanning it would be cut off at the frame's
|
|
706
|
-
edge. */}
|
|
707
|
-
{page.docs.length === 0 ? (
|
|
708
|
-
<div data-list-message="" className="text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm">
|
|
709
|
-
{filtered ? (
|
|
710
|
-
<span className="flex flex-col items-center gap-2">
|
|
711
|
-
{t('admin.noMatches', 'No rows match this filter.') ?? 'No rows match this filter.'}
|
|
712
|
-
<Button type="button" variant="outline" size="sm" className="max-sm:h-11" onClick={() => refine({ search: undefined, where: undefined })}>
|
|
713
|
-
{t('admin.clearFilters', 'Clear filters') ?? 'Clear filters'}
|
|
714
|
-
</Button>
|
|
715
|
-
</span>
|
|
716
|
-
) : Empty && screen ? (
|
|
717
|
-
<Empty page={screen.page} />
|
|
718
|
-
) : (
|
|
719
|
-
(t(`${kayan.name}.none`) ?? t('admin.empty', 'Nothing here yet.') ?? 'Nothing here yet.')
|
|
720
|
-
)}
|
|
721
|
-
</div>
|
|
722
|
-
) : unreadable ? (
|
|
723
|
-
// Rows with no column to draw would read as records with nothing in them, so the list says what it
|
|
724
|
-
// cannot show, and whom to ask
|
|
725
|
-
<div data-list-message="" className="text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm">
|
|
726
|
-
{t('admin.noColumns', 'Your role can\'t see any column of this list. Ask an administrator of your organization for access.')}
|
|
727
|
-
</div>
|
|
728
|
-
) : null}
|
|
729
|
-
{more === false ? null : <div data-scroll-cue="" aria-hidden className="from-card pointer-events-none absolute inset-y-0 w-10 bg-linear-to-l to-transparent rtl:bg-linear-to-r" style={{ insetInlineEnd: more }} />}
|
|
730
|
-
</div>
|
|
731
|
-
|
|
732
|
-
<span id={openHint} hidden>
|
|
733
|
-
{t('admin.openRow', 'Press Enter to open this row.') ?? 'Press Enter to open this row.'}
|
|
734
|
-
</span>
|
|
735
|
-
|
|
736
|
-
{/* Drawn once below the table rather than inside the menu, which closes as its item is pressed and would
|
|
737
|
-
take a dialog inside it along. */}
|
|
738
|
-
{deletes ? (
|
|
739
|
-
<DeleteButton
|
|
740
|
-
busy={running}
|
|
741
|
-
copy={copy}
|
|
742
|
-
kayan={kayan}
|
|
743
|
-
label={t('admin.delete', 'Delete') ?? 'Delete'}
|
|
744
|
-
open={deleting !== undefined}
|
|
745
|
-
onOpenChange={(open) => (open ? undefined : setDeleting(undefined))}
|
|
746
|
-
row={deleting}
|
|
747
|
-
onDelete={(refuse) => runOnRow('delete', { id: deleting?.id }, refuse)}
|
|
748
|
-
/>
|
|
749
|
-
) : null}
|
|
750
|
-
{asking ? (
|
|
751
|
-
<OperationButton
|
|
752
|
-
api={api}
|
|
753
|
-
busy={running}
|
|
754
|
-
copy={copy}
|
|
755
|
-
description={rowWarning({ kayan, row: asking.row, operation: asking.operation.name, copy })}
|
|
756
|
-
inputOf={(values) => ({ ...values, id: asking.row.id })}
|
|
757
|
-
kayan={kayan}
|
|
758
|
-
kayans={client}
|
|
759
|
-
operation={asking.operation}
|
|
760
|
-
open
|
|
761
|
-
onOpenChange={(open) => (open ? undefined : setAsking(undefined))}
|
|
762
|
-
run={(input, refuse) => runOnRow(asking.operation.name, input, refuse)}
|
|
763
|
-
/>
|
|
764
|
-
) : null}
|
|
765
|
-
{over}
|
|
766
|
-
|
|
767
|
-
{unreadable ? null : (
|
|
768
|
-
<Pagination
|
|
769
|
-
page={page}
|
|
770
|
-
t={t}
|
|
771
|
-
locale={locale}
|
|
772
|
-
onPage={(next) => onQuery({ ...query, page: next })}
|
|
773
|
-
onLimit={(limit) => refine({ limit })}
|
|
774
|
-
/>
|
|
775
|
-
)}
|
|
776
|
-
|
|
777
|
-
{edits && editing !== undefined ? (
|
|
778
|
-
<div data-mithaq-inline-edit={editing ? String(editing.id ?? '') : 'new'} className="bg-muted/30 rounded-md border p-3">
|
|
779
|
-
<Form
|
|
780
|
-
kayan={kayan}
|
|
781
|
-
screen={screen && { ...screen, kayan }}
|
|
782
|
-
nodes={node!.columns}
|
|
783
|
-
client={edits.client}
|
|
784
|
-
writes={edits.writes}
|
|
785
|
-
value={editing}
|
|
786
|
-
t={t}
|
|
787
|
-
locale={locale}
|
|
788
|
-
locales={locales}
|
|
789
|
-
kayans={client}
|
|
790
|
-
api={api}
|
|
791
|
-
onDone={() => setEditing(undefined)}
|
|
792
|
-
onCancel={() => setEditing(undefined)}
|
|
793
|
-
/>
|
|
794
|
-
</div>
|
|
795
|
-
) : null}
|
|
796
|
-
</div>
|
|
797
|
-
</CardContent>
|
|
798
|
-
</Card>
|
|
799
|
-
</section>
|
|
800
|
-
)
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function ListButton({
|
|
804
|
-
api,
|
|
805
|
-
client,
|
|
806
|
-
copy,
|
|
807
|
-
description,
|
|
808
|
-
disabled,
|
|
809
|
-
inputOf,
|
|
810
|
-
kayan,
|
|
811
|
-
onRan,
|
|
812
|
-
onRefused,
|
|
813
|
-
operation,
|
|
814
|
-
run,
|
|
815
|
-
screen,
|
|
816
|
-
}: {
|
|
817
|
-
api?: FindApi
|
|
818
|
-
client?: readonly ClientKayan[]
|
|
819
|
-
copy: Copy
|
|
820
|
-
description?: string
|
|
821
|
-
disabled?: boolean
|
|
822
|
-
inputOf: (values: Record<string, unknown>) => Record<string, unknown>
|
|
823
|
-
kayan: ClientKayan
|
|
824
|
-
onRan?: (written: unknown) => void
|
|
825
|
-
onRefused: (thrown: unknown) => void
|
|
826
|
-
operation: PageButton
|
|
827
|
-
run: (operation: string, input: Record<string, unknown>) => Promise<unknown>
|
|
828
|
-
screen?: ScreenPage
|
|
829
|
-
}): ReactElement {
|
|
830
|
-
const [busy, setBusy] = useState(false)
|
|
831
|
-
const call = async (input: Record<string, unknown>, refuse: (thrown: unknown) => void = onRefused) => {
|
|
832
|
-
// Every path ends here, so a button that ignores `busy` sends nothing too.
|
|
833
|
-
if (disabled) return false
|
|
834
|
-
setBusy(true)
|
|
835
|
-
onRefused(undefined)
|
|
836
|
-
try {
|
|
837
|
-
onRan?.(await run(operation.name, input))
|
|
838
|
-
return true
|
|
839
|
-
} catch (thrown) {
|
|
840
|
-
refuse(thrown)
|
|
841
|
-
return false
|
|
842
|
-
} finally {
|
|
843
|
-
setBusy(false)
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
const Own = refOf({ screen, place: 'button', path: operation.component })
|
|
847
|
-
if (Own && screen) {
|
|
848
|
-
return <OwnButton Own={Own} operation={operation} busy={busy || disabled === true} copy={copy} call={async (input: Row) => void (await call(inputOf(input)))} />
|
|
849
|
-
}
|
|
850
|
-
if (!operation.fields?.length) return <NodeButton node={operation} copy={copy} busy={busy || disabled === true} description={description} run={() => void call(inputOf({}))} />
|
|
851
|
-
return (
|
|
852
|
-
<OperationButton
|
|
853
|
-
api={api}
|
|
854
|
-
busy={busy || disabled === true}
|
|
855
|
-
copy={copy}
|
|
856
|
-
description={description}
|
|
857
|
-
inputOf={inputOf}
|
|
858
|
-
kayan={kayan}
|
|
859
|
-
kayans={client}
|
|
860
|
-
operation={operation}
|
|
861
|
-
run={call}
|
|
862
|
-
/>
|
|
863
|
-
)
|
|
864
|
-
}
|
|
865
|
-
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 97acf94d409745118e2ec5a6f107c4ef0ff08f4ec9202ba97fb24f8408de7ddf
|