create-mithaq 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +56 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/copy.ts +870 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +159 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState, type ComponentProps } from 'react'
|
|
2
|
+
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { authArm } from '../api.ts'
|
|
5
|
+
import { documentHead } from '../copy.ts'
|
|
6
|
+
import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
|
|
7
|
+
import { useCopy } from './__root.tsx'
|
|
8
|
+
import { wanted } from '../redirect.ts'
|
|
9
|
+
|
|
10
|
+
// The password has no length rule here because the provider's sign-in has none: a wrong
|
|
11
|
+
// password of any length is the one refusal it answers.
|
|
12
|
+
|
|
13
|
+
export const Route = createFileRoute('/auth/sign-in')({
|
|
14
|
+
head: ({ match }) => documentHead(match.context, (t) => t('auth.signIn')),
|
|
15
|
+
// The router hands `validateSearch` whatever the URL carried; `wanted` decides what is honoured.
|
|
16
|
+
validateSearch: redirectSearch,
|
|
17
|
+
beforeLoad: gate,
|
|
18
|
+
component: SignIn,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function SignIn() {
|
|
22
|
+
const navigate = useNavigate()
|
|
23
|
+
const { t } = useCopy()
|
|
24
|
+
const search = Route.useSearch()
|
|
25
|
+
const refused = useRefusal()
|
|
26
|
+
const [email, setEmail] = useState('')
|
|
27
|
+
const [password, setPassword] = useState('')
|
|
28
|
+
const [pending, setPending] = useState(false)
|
|
29
|
+
const [refusal, setRefusal] = useState<string | null>(null)
|
|
30
|
+
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
31
|
+
const required = t('auth.required', 'This field is required.')
|
|
32
|
+
const wrong = {
|
|
33
|
+
email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
|
|
34
|
+
password: () => (password ? undefined : required),
|
|
35
|
+
}
|
|
36
|
+
const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
|
|
37
|
+
const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'>) => (
|
|
38
|
+
<AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const submit = async (event: React.FormEvent) => {
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
setLeft(new Set(Object.keys(wrong)))
|
|
44
|
+
if (Object.values(wrong).some((check) => check())) return
|
|
45
|
+
setPending(true)
|
|
46
|
+
setRefusal(null)
|
|
47
|
+
const answered = await authArm('sign-in/email', { email, password })
|
|
48
|
+
// The project's own sentence for the provider's code, never its English `message`, since copy carries every locale.
|
|
49
|
+
if (!answered.ok) {
|
|
50
|
+
setPending(false)
|
|
51
|
+
return setRefusal(refused(answered, t('auth.failed', 'That sign-in did not work.')))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setPending(false)
|
|
55
|
+
|
|
56
|
+
// The first screen this actor enters is read only once the session exists.
|
|
57
|
+
await navigate({ to: wanted({ asked: search.redirect, home: (await authGate()).home.path }) })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<AuthFrame title={t('auth.signIn', 'Sign in')} description={t('auth.signInDescription', 'Sign in to reach the dashboard.')}>
|
|
62
|
+
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
63
|
+
{box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
|
|
64
|
+
{box('password', t('auth.password', 'Password'), { autoComplete: 'current-password', onChange: (event) => setPassword(event.target.value), type: 'password', value: password })}
|
|
65
|
+
<Link className="-mt-2 justify-self-end text-sm text-muted-foreground underline-offset-4 hover:underline" to="/auth/forgot-password" search={{ redirect: search.redirect }}>
|
|
66
|
+
{t('auth.forgotPasswordQuestion', 'Forgot password?')}
|
|
67
|
+
</Link>
|
|
68
|
+
{/* `role="alert"` so a refusal is spoken when it appears: nothing moves the focus to it. */}
|
|
69
|
+
{refusal && (
|
|
70
|
+
<p className="text-destructive text-sm" role="alert">
|
|
71
|
+
{refusal}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
<Button disabled={pending} type="submit">
|
|
75
|
+
{pending ? t('auth.signInPending', 'Signing in…') : t('auth.signIn', 'Sign in')}
|
|
76
|
+
</Button>
|
|
77
|
+
</form>
|
|
78
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
79
|
+
{t('auth.noAccount', 'No account yet?')}{' '}
|
|
80
|
+
<Link className="text-primary underline underline-offset-4" to="/auth/sign-up" search={{ redirect: search.redirect }}>
|
|
81
|
+
{t('auth.signUp', 'Sign up')}
|
|
82
|
+
</Link>
|
|
83
|
+
</p>
|
|
84
|
+
</AuthFrame>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState, type ComponentProps } from 'react'
|
|
2
|
+
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { authArm } from '../api.ts'
|
|
5
|
+
import { documentHead } from '../copy.ts'
|
|
6
|
+
import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
|
|
7
|
+
import { wanted } from '../redirect.ts'
|
|
8
|
+
import { useCopy } from './__root.tsx'
|
|
9
|
+
|
|
10
|
+
export const Route = createFileRoute('/auth/sign-up')({
|
|
11
|
+
head: ({ match }) => documentHead(match.context, (t) => t('auth.signUp')),
|
|
12
|
+
validateSearch: redirectSearch,
|
|
13
|
+
beforeLoad: gate,
|
|
14
|
+
component: SignUp,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Better Auth requires a unique slug, so a random tail keeps same-named organizations apart.
|
|
19
|
+
* Padded before the cut, so the tail is always six characters and the slug never ends in `-`.
|
|
20
|
+
*/
|
|
21
|
+
const slugOf = (name: string) =>
|
|
22
|
+
`${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'organization'}-${Math.random().toString(36).slice(2).padEnd(6, '0').slice(0, 6)}`
|
|
23
|
+
|
|
24
|
+
function SignUp() {
|
|
25
|
+
const navigate = useNavigate()
|
|
26
|
+
const { t } = useCopy()
|
|
27
|
+
const search = Route.useSearch()
|
|
28
|
+
const refused = useRefusal()
|
|
29
|
+
const { password: bounds } = Route.useRouteContext()
|
|
30
|
+
// The redirect is a link anyone can write; a crafted one only makes an account with no organization,
|
|
31
|
+
// which reads nothing until an organization invites it.
|
|
32
|
+
const invited = wanted({ asked: search.redirect, home: '/' }).startsWith('/auth/accept-invitation?')
|
|
33
|
+
const [name, setName] = useState('')
|
|
34
|
+
const [organization, setOrganization] = useState('')
|
|
35
|
+
const [email, setEmail] = useState('')
|
|
36
|
+
const [password, setPassword] = useState('')
|
|
37
|
+
const [confirmation, setConfirmation] = useState('')
|
|
38
|
+
const [pending, setPending] = useState(false)
|
|
39
|
+
const [refusal, setRefusal] = useState<string | null>(null)
|
|
40
|
+
// The address this submit already signed up. A second sign-up with the same email is refused, so a
|
|
41
|
+
// retry after the organization call failed must skip it. An address and not a flag: a corrected typo signs up again.
|
|
42
|
+
const [signedUpAs, setSignedUpAs] = useState<string | null>(null)
|
|
43
|
+
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
44
|
+
const required = t('auth.required', 'This field is required.')
|
|
45
|
+
const wrong = {
|
|
46
|
+
name: () => (name ? undefined : required),
|
|
47
|
+
organization: () => (invited || organization ? undefined : required),
|
|
48
|
+
email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
|
|
49
|
+
password: () =>
|
|
50
|
+
!password ? required
|
|
51
|
+
: password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
|
|
52
|
+
: password.length > bounds.max ? t('auth.passwordTooLong', 'Password too long')
|
|
53
|
+
: undefined,
|
|
54
|
+
confirmation: () => (!confirmation ? required : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
|
|
55
|
+
}
|
|
56
|
+
const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
|
|
57
|
+
const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
|
|
58
|
+
<AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const submit = async (event: React.FormEvent) => {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
setLeft(new Set(Object.keys(wrong)))
|
|
64
|
+
if (Object.values(wrong).some((check) => check())) return
|
|
65
|
+
setPending(true)
|
|
66
|
+
setRefusal(null)
|
|
67
|
+
if (signedUpAs !== email) {
|
|
68
|
+
const made = await authArm('sign-up/email', { name, email, password })
|
|
69
|
+
if (!made.ok) {
|
|
70
|
+
setPending(false)
|
|
71
|
+
return setRefusal(refused(made, t('auth.signUpFailed', 'That sign-up did not work.')))
|
|
72
|
+
}
|
|
73
|
+
setSignedUpAs(email)
|
|
74
|
+
}
|
|
75
|
+
// A refusal here leaves an account that reads nothing, so the sentence names the organization;
|
|
76
|
+
// `signedUpAs` holds a retry to this call.
|
|
77
|
+
const created = invited ? undefined : await authArm('organization/create', { name: organization, slug: slugOf(organization) })
|
|
78
|
+
setPending(false)
|
|
79
|
+
if (created && !created.ok) return setRefusal(refused(created, t('auth.organizationFailed', 'That organization could not be created.')))
|
|
80
|
+
await navigate({ to: wanted({ asked: search.redirect, home: (await authGate()).home.path }) })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<AuthFrame
|
|
85
|
+
title={t('auth.signUp', 'Sign up')}
|
|
86
|
+
description={invited ? t('auth.signUpInvitedDescription', 'Make an account with the email address the invitation was sent to.') : t('auth.signUpDescription', 'Make an account and its organization.')}
|
|
87
|
+
>
|
|
88
|
+
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
89
|
+
{box('name', t('auth.name', 'Name'), { autoComplete: 'name', onChange: (event) => setName(event.target.value), type: 'text', value: name })}
|
|
90
|
+
{invited ? null : box('organization', t('auth.organization', 'Organization'), { autoComplete: 'organization', onChange: (event) => setOrganization(event.target.value), type: 'text', value: organization })}
|
|
91
|
+
{box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
|
|
92
|
+
{box('password', t('auth.password', 'Password'), {
|
|
93
|
+
autoComplete: 'new-password',
|
|
94
|
+
description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
|
|
95
|
+
onChange: (event) => setPassword(event.target.value),
|
|
96
|
+
type: 'password',
|
|
97
|
+
value: password,
|
|
98
|
+
})}
|
|
99
|
+
{box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
|
|
100
|
+
autoComplete: 'new-password',
|
|
101
|
+
onChange: (event) => setConfirmation(event.target.value),
|
|
102
|
+
type: 'password',
|
|
103
|
+
value: confirmation,
|
|
104
|
+
})}
|
|
105
|
+
{refusal && (
|
|
106
|
+
<p className="text-destructive text-sm" role="alert">
|
|
107
|
+
{refusal}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
<Button disabled={pending} type="submit">
|
|
111
|
+
{pending ? t('auth.signUpPending', 'Signing up…') : t('auth.signUp', 'Sign up')}
|
|
112
|
+
</Button>
|
|
113
|
+
</form>
|
|
114
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
115
|
+
{t('auth.haveAccount', 'Already have an account?')}{' '}
|
|
116
|
+
<Link className="text-primary underline underline-offset-4" to="/auth/sign-in" search={{ redirect: search.redirect }}>
|
|
117
|
+
{t('auth.signIn', 'Sign in')}
|
|
118
|
+
</Link>
|
|
119
|
+
</p>
|
|
120
|
+
</AuthFrame>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { BookOpen, LayoutDashboard, Languages, ShieldCheck, Users } from 'lucide-react'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { useHomeLabel } from '../auth-frame.tsx'
|
|
6
|
+
import { LocaleSwitch } from '../locale-switch.tsx'
|
|
7
|
+
import { loadEnterable } from '../screens.tsx'
|
|
8
|
+
import { useCopy } from './__root.tsx'
|
|
9
|
+
|
|
10
|
+
// A host file because it reads `loadEnterable`, which only a host file may.
|
|
11
|
+
export const Route = createFileRoute('/')({
|
|
12
|
+
loader: () => loadEnterable(),
|
|
13
|
+
component: Landing,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function EntryActions({ screens, size }: { screens: readonly { name: string; path: string }[] | null; size: 'sm' | 'lg' }) {
|
|
17
|
+
const { t } = useCopy()
|
|
18
|
+
const label = useHomeLabel()
|
|
19
|
+
if (screens === null) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<Link to="/auth/sign-up">
|
|
23
|
+
<Button size={size}>{t('marketing.getStarted', 'Get started')}</Button>
|
|
24
|
+
</Link>
|
|
25
|
+
<Link to="/auth/sign-in">
|
|
26
|
+
<Button size={size} variant="outline">
|
|
27
|
+
{t('auth.signIn', 'Sign in')}
|
|
28
|
+
</Button>
|
|
29
|
+
</Link>
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
// A plain anchor: the path is the screen's own data, not a route the router knows.
|
|
34
|
+
const home: { path: string; name?: string } = screens[0] ?? { path: '/unauthorized' }
|
|
35
|
+
return (
|
|
36
|
+
<a href={home.path}>
|
|
37
|
+
<Button size={size}>{label(home, 'marketing.openScreen')}</Button>
|
|
38
|
+
</a>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Feature({ icon: Icon, title, description }: { icon: typeof BookOpen; title: string; description: string }) {
|
|
43
|
+
return (
|
|
44
|
+
<Card>
|
|
45
|
+
<CardHeader>
|
|
46
|
+
<Icon className="mb-2 h-6 w-6 text-primary" />
|
|
47
|
+
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
|
48
|
+
</CardHeader>
|
|
49
|
+
<CardContent className="text-sm text-muted-foreground">{description}</CardContent>
|
|
50
|
+
</Card>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Step({ number, title, description }: { number: number; title: string; description: string }) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex flex-col gap-2">
|
|
57
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold">
|
|
58
|
+
{number}
|
|
59
|
+
</div>
|
|
60
|
+
<h3 className="font-semibold">{title}</h3>
|
|
61
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Landing() {
|
|
67
|
+
const { t, locale } = useCopy()
|
|
68
|
+
const screens = Route.useLoaderData()?.screens ?? null
|
|
69
|
+
// `t` answers `string | undefined` for a key no locale carries; the fallback only satisfies that type.
|
|
70
|
+
const tt = (key: string, fallback: string) => t(key, fallback) ?? fallback
|
|
71
|
+
const features = [
|
|
72
|
+
{ icon: BookOpen, title: tt('marketing.featureCoursesTitle', 'Courses and sessions'), description: tt('marketing.featureCoursesDescription', '') },
|
|
73
|
+
{ icon: Users, title: tt('marketing.featureEnrollmentsTitle', 'Enrollments'), description: tt('marketing.featureEnrollmentsDescription', '') },
|
|
74
|
+
{ icon: ShieldCheck, title: tt('marketing.featureRolesTitle', 'Roles and permissions'), description: tt('marketing.featureRolesDescription', '') },
|
|
75
|
+
{ icon: Languages, title: tt('marketing.featureBilingualTitle', 'Arabic and English, either direction'), description: tt('marketing.featureBilingualDescription', '') },
|
|
76
|
+
{ icon: LayoutDashboard, title: tt('marketing.featurePortalTitle', 'A dashboard and a customer portal'), description: tt('marketing.featurePortalDescription', '') },
|
|
77
|
+
]
|
|
78
|
+
const steps = [
|
|
79
|
+
{ title: tt('marketing.howStep1Title', 'Set up your organization'), description: tt('marketing.howStep1Description', '') },
|
|
80
|
+
{ title: tt('marketing.howStep2Title', 'Add courses and roles'), description: tt('marketing.howStep2Description', '') },
|
|
81
|
+
{ title: tt('marketing.howStep3Title', 'Open the portal to your customers'), description: tt('marketing.howStep3Description', '') },
|
|
82
|
+
]
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex min-h-screen flex-col">
|
|
85
|
+
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
86
|
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
|
87
|
+
<span className="text-lg font-semibold">{t('marketing.brand', 'Asas')}</span>
|
|
88
|
+
<div className="flex items-center gap-1 sm:gap-2">
|
|
89
|
+
<LocaleSwitch locale={locale} />
|
|
90
|
+
<EntryActions screens={screens} size="sm" />
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
<main className="flex-1">
|
|
96
|
+
<section className="mx-auto flex max-w-4xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6 sm:py-28">
|
|
97
|
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('marketing.heroTitle', 'Run courses and enrollments without the busywork')}</h1>
|
|
98
|
+
<p className="max-w-2xl text-lg text-muted-foreground">{t('marketing.heroSubtitle', '')}</p>
|
|
99
|
+
<div className="flex flex-col gap-3 sm:flex-row">
|
|
100
|
+
<EntryActions screens={screens} size="lg" />
|
|
101
|
+
</div>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
|
|
105
|
+
<div className="mx-auto mb-12 max-w-2xl text-center">
|
|
106
|
+
<h2 className="text-3xl font-bold tracking-tight">{t('marketing.featuresTitle', 'Everything a training organization needs')}</h2>
|
|
107
|
+
<p className="mt-3 text-muted-foreground">{t('marketing.featuresSubtitle', '')}</p>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
110
|
+
{features.map((feature) => (
|
|
111
|
+
<Feature key={feature.title} {...feature} />
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
<section className="border-t bg-muted/30">
|
|
117
|
+
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
|
|
118
|
+
<h2 className="mb-10 text-center text-3xl font-bold tracking-tight">{t('marketing.howTitle', 'How it works')}</h2>
|
|
119
|
+
<div className="grid gap-8 sm:grid-cols-3">
|
|
120
|
+
{steps.map((step, at) => (
|
|
121
|
+
<Step key={step.title} number={at + 1} {...step} />
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section className="mx-auto flex max-w-3xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6">
|
|
128
|
+
<h2 className="text-3xl font-bold tracking-tight">{t('marketing.ctaTitle', 'Start running your courses on Asas')}</h2>
|
|
129
|
+
<p className="text-muted-foreground">{t('marketing.ctaSubtitle', '')}</p>
|
|
130
|
+
<EntryActions screens={screens} size="lg" />
|
|
131
|
+
</section>
|
|
132
|
+
</main>
|
|
133
|
+
|
|
134
|
+
<footer className="border-t py-8">
|
|
135
|
+
<div className="mx-auto max-w-6xl px-4 text-center text-sm text-muted-foreground sm:px-6">
|
|
136
|
+
{t('marketing.footer', 'Asas — courses and enrollments for training organizations.')}
|
|
137
|
+
</div>
|
|
138
|
+
</footer>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 32a4513d4cf4a984b7845056965d48edff53542ce962b466af67a87983832a6d
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Fragment, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { authArm } from '../api.ts'
|
|
5
|
+
import { AuthFrame, useRefusal } from '../auth-frame.tsx'
|
|
6
|
+
import { documentHead } from '../copy.ts'
|
|
7
|
+
import { ImpersonationBar, loadEnterable } from '../screens.tsx'
|
|
8
|
+
import { useCopy } from './__root.tsx'
|
|
9
|
+
|
|
10
|
+
export const Route = createFileRoute('/unauthorized')({
|
|
11
|
+
// The path a screen refused; the server only matches it against the build's pages.
|
|
12
|
+
validateSearch: (search: Record<string, unknown>): { from?: string } => (typeof search.from === 'string' ? { from: search.from } : {}),
|
|
13
|
+
loaderDeps: ({ search }) => ({ from: search.from }),
|
|
14
|
+
loader: async ({ deps, context }) => {
|
|
15
|
+
const entered = await loadEnterable({ data: { from: deps.from, locale: context.locale } })
|
|
16
|
+
// Carries this page's own path so a visitor whose session expired lands back here after signing in.
|
|
17
|
+
if (!entered) throw redirect({ to: '/auth/sign-in', search: { redirect: '/unauthorized' } })
|
|
18
|
+
return entered
|
|
19
|
+
},
|
|
20
|
+
// After the loader, so its data's type is inferred before this reads it.
|
|
21
|
+
head: ({ match, loaderData }) => documentHead(match.context, (t) => (loaderData?.screens.length === 0 ? t('auth.noAccess') : t('auth.unauthorized'))),
|
|
22
|
+
component: Unauthorized,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function Unauthorized() {
|
|
26
|
+
const { t } = useCopy()
|
|
27
|
+
const { screens, refused, email, impersonating, organizations = [], others = [] } = Route.useLoaderData()
|
|
28
|
+
const refusal = useRefusal()
|
|
29
|
+
const [switching, setSwitching] = useState<string>()
|
|
30
|
+
const [failed, setFailed] = useState<string>()
|
|
31
|
+
// The slug sits beside the name where another membership shares the name.
|
|
32
|
+
const memberships = [...organizations, ...others]
|
|
33
|
+
const named = (one: { id: string; name: string; slug?: string }): ReactNode =>
|
|
34
|
+
one.slug && memberships.some((other) => other.id !== one.id && other.name === one.name) ? (
|
|
35
|
+
<>
|
|
36
|
+
{one.name} <span dir="ltr" data-slug="" className="text-muted-foreground text-xs font-normal">{one.slug}</span>
|
|
37
|
+
</>
|
|
38
|
+
) : (
|
|
39
|
+
one.name
|
|
40
|
+
)
|
|
41
|
+
// Each name is isolated so an email or Latin name in an Arabic sentence keeps
|
|
42
|
+
// its order, and is one inline box that wraps whole, so a long address never
|
|
43
|
+
// runs past the card on a phone.
|
|
44
|
+
const whole = 'inline-block max-w-full [overflow-wrap:anywhere]'
|
|
45
|
+
const said = (text: string | undefined) =>
|
|
46
|
+
text!.split(/(\{email\}|\{organization\}|\{page\})/).map((part, at) =>
|
|
47
|
+
part === '{email}' ? <bdi key={at} className={whole}>{email}</bdi>
|
|
48
|
+
: part === '{organization}' ? <bdi key={at}>{organizations.map((one, index) => <Fragment key={one.id}>{index ? ', ' : ''}<span className={whole}>{named(one)}</span></Fragment>)}</bdi>
|
|
49
|
+
: part === '{page}' ? <bdi key={at}>{refused?.page}</bdi>
|
|
50
|
+
: part)
|
|
51
|
+
const link = 'text-foreground underline underline-offset-4'
|
|
52
|
+
const screenName = (name: string) => t(`screens.${name}`, name) ?? name
|
|
53
|
+
return (
|
|
54
|
+
<AuthFrame
|
|
55
|
+
title={screens.length ? t('auth.unauthorized', 'Not for your role') : t('auth.noAccess', 'Your account has no access yet')}
|
|
56
|
+
description={
|
|
57
|
+
<span data-no-access={screens.length ? undefined : ''}>
|
|
58
|
+
{refused
|
|
59
|
+
? said(t('auth.unauthorizedPage', 'Your role does not open that page: {page}.'))
|
|
60
|
+
: screens.length
|
|
61
|
+
? t('auth.unauthorizedDescription', 'Your role does not open that screen. These are the screens it opens:')
|
|
62
|
+
: organizations.length
|
|
63
|
+
? said(t('auth.noAccessDescription', '{email} is a member of {organization} and holds no role that opens a screen here. Ask an administrator of {organization} to give you one.'))
|
|
64
|
+
: said(t('auth.noOrganization', '{email} belongs to no organization yet. Ask an administrator to invite you.'))}
|
|
65
|
+
</span>
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
<div className="grid gap-4">
|
|
69
|
+
{/* Stopping impersonation is the way out in place of Sign out. */}
|
|
70
|
+
{impersonating ? <ImpersonationBar name={impersonating} /> : null}
|
|
71
|
+
{refused ? (
|
|
72
|
+
<a className={link} href={refused.screen.path}>
|
|
73
|
+
{(t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => screenName(refused.screen.name))}
|
|
74
|
+
</a>
|
|
75
|
+
) : screens.length ? (
|
|
76
|
+
<ul className="grid gap-2">
|
|
77
|
+
{screens.map((screen) => (
|
|
78
|
+
<li key={screen.name}>
|
|
79
|
+
{/* An anchor: the path is the screen's own data, not a route the router knows. */}
|
|
80
|
+
<a className={link} href={screen.path}>
|
|
81
|
+
{screenName(screen.name)}
|
|
82
|
+
</a>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
) : null}
|
|
87
|
+
{/* So a switch into an organization where the actor holds no role is not a dead end. */}
|
|
88
|
+
{others.map((one) => (
|
|
89
|
+
<Button
|
|
90
|
+
key={one.id}
|
|
91
|
+
type="button"
|
|
92
|
+
variant="secondary"
|
|
93
|
+
data-switch-organization={one.id}
|
|
94
|
+
// Busy while it runs, so a second press sends nothing.
|
|
95
|
+
disabled={switching !== undefined}
|
|
96
|
+
aria-busy={switching === one.id || undefined}
|
|
97
|
+
onClick={() => {
|
|
98
|
+
setSwitching(one.id)
|
|
99
|
+
setFailed(undefined)
|
|
100
|
+
void authArm('organization/set-active', { organizationId: one.id }).then((answer) => {
|
|
101
|
+
if (answer.ok) return window.location.assign('/auth/sign-in')
|
|
102
|
+
setSwitching(undefined)
|
|
103
|
+
setFailed(refusal(answer, t('auth.requestFailed', 'That request did not work.')))
|
|
104
|
+
})
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{/* One box, so the button's gap does not open beside the name. */}
|
|
108
|
+
<span>{(t('auth.switchTo', 'Switch to {organization}') ?? 'Switch to {organization}').split(/(\{organization\})/).map((part, at) => (part === '{organization}' ? <bdi key={at}>{named(one)}</bdi> : part))}</span>
|
|
109
|
+
</Button>
|
|
110
|
+
))}
|
|
111
|
+
{failed ? <p role="alert" className="text-destructive text-sm">{failed}</p> : null}
|
|
112
|
+
{/* `replace`, so Back does not return to the page signed out of. */}
|
|
113
|
+
{impersonating ? null : (
|
|
114
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={() => void authArm('sign-out').then(() => window.location.replace('/auth/sign-in'))}>
|
|
115
|
+
{t('admin.signOut', 'Sign out')}
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</AuthFrame>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 3c5c304b3892c56759bf780f155ef3aa154ce3cc954e3d240b3537cdd97da735
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useBlocker, useRouter } from '@tanstack/react-router'
|
|
2
|
+
import { useState, type ReactElement } from 'react'
|
|
3
|
+
import { Form, type FormProps } from './components/mithaq/form.tsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A kayan's form on a screen's page. A create moves to the new row's edit page, a delete to the list.
|
|
7
|
+
* While the form holds a change the router blocks navigation and the form asks its discard question.
|
|
8
|
+
*/
|
|
9
|
+
export function ScreenForm({ edit, list, ...props }: Omit<FormProps, 'onDone' | 'onDirty' | 'onCancel' | 'blocked'> & {
|
|
10
|
+
/** The href of a row's edit page, or nothing where the kayan has none. */
|
|
11
|
+
edit: (id: string) => string | undefined
|
|
12
|
+
/** The href of the kayan's list, or nothing where it has none. */
|
|
13
|
+
list?: string
|
|
14
|
+
}): ReactElement {
|
|
15
|
+
const router = useRouter()
|
|
16
|
+
const [dirty, setDirty] = useState(false)
|
|
17
|
+
const blocker = useBlocker({ shouldBlockFn: () => dirty, withResolver: true, enableBeforeUnload: false })
|
|
18
|
+
// `ignoreBlocker`: the form reports clean only on the render after the save.
|
|
19
|
+
const go = (href: string | undefined) => void (href ? router.navigate({ href, ignoreBlocker: true }) : router.invalidate())
|
|
20
|
+
const landing = (row: unknown, effect?: string) => {
|
|
21
|
+
const id = (row as { id?: unknown } | null | undefined)?.id
|
|
22
|
+
return effect === 'delete' ? list : effect === 'create' && id !== undefined ? edit(String(id)) : undefined
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<Form
|
|
26
|
+
{...props}
|
|
27
|
+
onDirty={setDirty}
|
|
28
|
+
onDone={(row, effect) => go(landing(row, effect))}
|
|
29
|
+
onCancel={list ? () => go(list) : undefined}
|
|
30
|
+
blocked={blocker.status === 'blocked' ? blocker : undefined}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 7284b5e97770fd0c395fafa0b7459cab4ceb458e4bc982dc8233894126116170
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useState, type ReactElement } from 'react'
|
|
3
|
+
import { ListContext, useScreen } from 'mithaq/admin/react'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { List, type ListProps, type Page } from './components/mithaq/list.tsx'
|
|
6
|
+
import { QueryRefused, findOf, readQuery, readRaw, rulesOf, scopedTo, writeQuery, type ListQuery, type RawQuery } from './components/mithaq/list-query.ts'
|
|
7
|
+
import { counted } from './components/mithaq/controls.tsx'
|
|
8
|
+
import { listOf } from './components/mithaq/nodes.tsx'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The list's search as the router carries it: the URL's pairs as strings, each
|
|
12
|
+
* `where[…]` pair a key of its own. The router's default would JSON-quote a
|
|
13
|
+
* page number, so an app hands these two to `createRouter`.
|
|
14
|
+
*/
|
|
15
|
+
export const flatSearch = {
|
|
16
|
+
parseSearch: (search: string) => Object.fromEntries(new URLSearchParams(search)),
|
|
17
|
+
stringifySearch: (search: Record<string, unknown>) => {
|
|
18
|
+
const written = new URLSearchParams(Object.entries(search).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]]))).toString()
|
|
19
|
+
return written ? `?${written}` : ''
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A page route's search: the list's own keys, each the string the URL carries, read against the list once the loader has the kayan. */
|
|
24
|
+
export const askedFor = (search: Record<string, unknown>): RawQuery => readRaw(search)
|
|
25
|
+
|
|
26
|
+
/** Every page of one kayan's list the browser holds, which a write to that kayan invalidates. */
|
|
27
|
+
export const rowsKey = (kayan: string) => ['mithaq', kayan] as const
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a list of this kayan reads with `draft: true`: the kayan keeps drafts
|
|
31
|
+
* and this actor holds `findVersions`. Then never-published rows are listed,
|
|
32
|
+
* each shown as its latest draft, and the total is counted the same way.
|
|
33
|
+
*/
|
|
34
|
+
export const readsDrafts = (kayan: Pick<ListProps['kayan'], 'drafts'>, operations?: Readonly<Record<string, boolean>>) =>
|
|
35
|
+
!!kayan.drafts && operations?.findVersions === true
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether this actor edits one row's drafts: it holds `findVersions`, and this row is within any limit on it.
|
|
39
|
+
* A row left out gets a plain Save and no autosave, since the server answers a draft save on it 404.
|
|
40
|
+
*/
|
|
41
|
+
export const rowReadsDrafts = async ({ kayan, operations, id, findVersions }: {
|
|
42
|
+
kayan: Pick<ListProps['kayan'], 'drafts'>
|
|
43
|
+
operations?: Readonly<Record<string, boolean>>
|
|
44
|
+
id?: string
|
|
45
|
+
findVersions: (input: { where: { parent: { equals: string } }; limit: 1 }) => Promise<{ docs: readonly unknown[] } | null>
|
|
46
|
+
}) => readsDrafts(kayan, operations) && (id === undefined || !!(await findVersions({ where: { parent: { equals: id } }, limit: 1 }))?.docs.length)
|
|
47
|
+
|
|
48
|
+
/** A query the URL asks for, or the sentence it was refused with, which asks nothing of the server. */
|
|
49
|
+
export const queryOf = (props: Pick<ListProps, 'kayan' | 'client' | 'locales' | 'screen' | 'node'> & { maxLimit: number }, raw: RawQuery): { query: ListQuery } | { refused: string } => {
|
|
50
|
+
try {
|
|
51
|
+
return { query: readQuery(raw, rulesOf({ kayan: props.kayan, node: props.node ?? listOf(props.screen), maxLimit: props.maxLimit, context: { client: props.client, locales: props.locales } })) }
|
|
52
|
+
} catch (thrown) {
|
|
53
|
+
if (thrown instanceof QueryRefused) return { refused: thrown.message }
|
|
54
|
+
throw thrown
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One screen's list, over the URL and TanStack Query. The URL is the list's one
|
|
60
|
+
* state; the server-rendered first page seeds the cache. A URL the list does
|
|
61
|
+
* not offer is refused before any request.
|
|
62
|
+
*/
|
|
63
|
+
export function ScreenList({
|
|
64
|
+
find,
|
|
65
|
+
first,
|
|
66
|
+
onQuery,
|
|
67
|
+
own,
|
|
68
|
+
props,
|
|
69
|
+
raw,
|
|
70
|
+
run,
|
|
71
|
+
scope,
|
|
72
|
+
}: {
|
|
73
|
+
props: Omit<ListProps, 'page' | 'query' | 'onQuery' | 'run'>
|
|
74
|
+
/** The URL's search, as the route validated it. */
|
|
75
|
+
raw: RawQuery
|
|
76
|
+
onQuery: (search: Record<string, string>) => void
|
|
77
|
+
/** The page the loader read, and the input it read it with, which seeds the cache where it is the page this URL asks for. */
|
|
78
|
+
first?: { input: unknown; rows: Page }
|
|
79
|
+
find: (input: Record<string, unknown>, options?: { draft?: boolean }) => Promise<Page>
|
|
80
|
+
run: (operation: string, input: Record<string, unknown>) => Promise<unknown>
|
|
81
|
+
/** What a list node's own component draws in place of the registry's list, reading the same props through `useList()`. */
|
|
82
|
+
own?: ReactElement
|
|
83
|
+
/** The rows a list of another kayan is held to: its relationship holding the page's row. */
|
|
84
|
+
scope?: Readonly<Record<string, unknown>>
|
|
85
|
+
}): ReactElement | null {
|
|
86
|
+
const queries = useQueryClient()
|
|
87
|
+
// Rows the last bulk left alone: never-published drafts, which the list shows and a bulk does not write.
|
|
88
|
+
const [left, setLeft] = useState(0)
|
|
89
|
+
const asked = queryOf({ ...props, maxLimit: useScreen().maxLimit }, raw)
|
|
90
|
+
const query = 'query' in asked ? asked.query : {}
|
|
91
|
+
const found = findOf({ kayan: props.kayan, query, node: props.node ?? listOf(props.screen), locales: props.locales, client: props.client })
|
|
92
|
+
const input = scope ? scopedTo(found, scope) : found
|
|
93
|
+
const seeded = first && JSON.stringify(first.input) === JSON.stringify(input) ? first.rows : undefined
|
|
94
|
+
const { data } = useQuery({
|
|
95
|
+
queryKey: [...rowsKey(props.kayan.name), 'find', input],
|
|
96
|
+
queryFn: () => find(input, readsDrafts(props.kayan, props.operations) ? { draft: true } : undefined),
|
|
97
|
+
enabled: 'query' in asked,
|
|
98
|
+
initialData: seeded,
|
|
99
|
+
placeholderData: (previous) => previous ?? first?.rows,
|
|
100
|
+
// Keeps the page the server just rendered from being read again as it
|
|
101
|
+
// hydrates. An organization or actor switch that does not reload the
|
|
102
|
+
// document must clear this cache, or the next actor sees the last one's pages.
|
|
103
|
+
staleTime: 30_000,
|
|
104
|
+
})
|
|
105
|
+
if ('refused' in asked) {
|
|
106
|
+
return (
|
|
107
|
+
<div role="alert" data-mithaq-refused={asked.refused} className="flex flex-col items-start gap-2 rounded-md border border-destructive/50 px-3 py-2 text-sm">
|
|
108
|
+
{props.t('admin.queryRefused', 'This link asks the list for something it does not offer, so nothing was read.')}
|
|
109
|
+
<Button type="button" variant="outline" size="sm" onClick={() => onQuery({})}>
|
|
110
|
+
{props.t('admin.clearFilters', 'Clear filters')}
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (!data) return null
|
|
116
|
+
const list: ListProps = {
|
|
117
|
+
...props,
|
|
118
|
+
page: data,
|
|
119
|
+
query,
|
|
120
|
+
onQuery: (next) => onQuery(writeQuery(next)),
|
|
121
|
+
run: async (operation, input) => {
|
|
122
|
+
const written = await run(operation, input)
|
|
123
|
+
setLeft((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0)
|
|
124
|
+
await queries.invalidateQueries({ queryKey: rowsKey(props.kayan.name) })
|
|
125
|
+
return written
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
return (
|
|
129
|
+
<ListContext value={list}>
|
|
130
|
+
{left ? (
|
|
131
|
+
<p role="status" data-mithaq-left={left} className="rounded-md border px-3 py-2 text-sm">
|
|
132
|
+
{counted({ t: props.t, locale: props.locale, key: 'admin.bulkLeft', count: left, fallback: '{count} of the ticked rows were left as they are, because they have never been published.' })}
|
|
133
|
+
</p>
|
|
134
|
+
) : null}
|
|
135
|
+
{own ?? <List {...list} />}
|
|
136
|
+
</ListContext>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 3c37825f9f645fd3f9c95a3d1ec554e863e85767e8329a1b2e16a40e5f5ea381
|