create-mithaq 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +521 -0
- package/package.json +19 -4
- package/template/apps/asas-backoffice/components.json +25 -0
- package/template/apps/asas-backoffice/gitignore +3 -0
- package/template/apps/asas-backoffice/package.json +57 -0
- package/template/apps/asas-backoffice/public/favicon.svg +1 -0
- package/template/apps/asas-backoffice/src/api.ts +147 -0
- package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
- package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/copy.ts +1 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
- package/template/apps/asas-backoffice/src/links.ts +38 -0
- package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
- 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-backoffice/src/request-headers.ts +11 -0
- 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-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
- package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
- 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-backoffice/src/routes/auth.sign-in.tsx +77 -0
- package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
- package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
- package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
- package/template/apps/asas-backoffice/src/screens.tsx +888 -0
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/asas-backoffice/src/slide.ts +22 -0
- package/template/apps/asas-backoffice/src/start.ts +42 -0
- package/template/apps/asas-backoffice/src/styles.css +202 -0
- package/template/apps/asas-backoffice/src/theme.ts +2 -0
- package/template/apps/asas-backoffice/tsconfig.json +19 -0
- package/template/apps/asas-backoffice/vite.config.ts +32 -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 +58 -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/choose-password.tsx +150 -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/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/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +1207 -0
- package/template/apps/asas-tanstack/src/first-run.ts +16 -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 +28 -0
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +39 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
- package/template/apps/asas-tanstack/src/router.tsx +18 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -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 +9 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -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 +108 -0
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -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 +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +888 -0
- package/template/apps/asas-tanstack/src/server.ts +26 -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 +163 -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 +34 -0
- package/template/package.json +16 -0
- 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 +33 -0
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1056 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +53 -0
- package/template/templates/asas/src/asas.ts +118 -0
- package/template/templates/asas/src/backoffice-http.ts +117 -0
- 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 +132 -0
- package/template/templates/asas/src/messages/auditLog.json +304 -0
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +312 -0
- package/template/templates/asas/src/messages/enrollments.json +60 -0
- package/template/templates/asas/src/messages/invitation.json +82 -0
- package/template/templates/asas/src/messages/localUsers.json +48 -0
- package/template/templates/asas/src/messages/media.json +76 -0
- package/template/templates/asas/src/messages/member.json +62 -0
- package/template/templates/asas/src/messages/memberRoles.json +48 -0
- package/template/templates/asas/src/messages/mithaq.json +214 -0
- package/template/templates/asas/src/messages/organization.json +72 -0
- package/template/templates/asas/src/messages/organizationRole.json +62 -0
- package/template/templates/asas/src/messages/roles.json +72 -0
- package/template/templates/asas/src/messages/session.json +102 -0
- package/template/templates/asas/src/messages/sessions.json +136 -0
- package/template/templates/asas/src/messages/settings.json +64 -0
- package/template/templates/asas/src/messages/tags.json +48 -0
- package/template/templates/asas/src/messages/user.json +112 -0
- package/template/templates/asas/src/messages/verification.json +62 -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/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/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +30 -0
- package/template/templates/asas/src/mithaq.ts +4 -0
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +427 -0
- package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
- package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
- 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 +829 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +73 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- 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/features/auth/local-users/local-users.ts +143 -0
- package/template/templates/features/auth/package.json +13 -0
- package/template/templates/features/auth/user/account.access.ts +8 -0
- package/template/templates/features/auth/user/account.ts +31 -0
- package/template/templates/features/auth/user/index.ts +18 -0
- package/template/templates/features/auth/user/invitation.access.ts +4 -0
- package/template/templates/features/auth/user/invitation.ts +27 -0
- package/template/templates/features/auth/user/member.access.ts +10 -0
- package/template/templates/features/auth/user/member.screens.ts +37 -0
- package/template/templates/features/auth/user/member.ts +26 -0
- package/template/templates/features/auth/user/organization.access.ts +20 -0
- package/template/templates/features/auth/user/organization.screens.ts +37 -0
- package/template/templates/features/auth/user/organization.ts +25 -0
- package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/features/auth/user/organizationRole.ts +25 -0
- package/template/templates/features/auth/user/session.access.ts +8 -0
- package/template/templates/features/auth/user/session.ts +28 -0
- package/template/templates/features/auth/user/user.access.ts +30 -0
- package/template/templates/features/auth/user/user.screens.ts +45 -0
- package/template/templates/features/auth/user/user.ts +31 -0
- package/template/templates/features/auth/user/verification.access.ts +8 -0
- package/template/templates/features/auth/user/verification.ts +23 -0
- package/template/templates/features/courses/courses/courses.ts +601 -0
- 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/features/courses/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
- package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
- 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/features/courses/enrollments/ui/session-cell.tsx +75 -0
- package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
- 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/features/courses/sessions/sessions.ts +186 -0
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/features/media/media/media.ts +131 -0
- package/template/templates/features/media/media/ui/preview.tsx +143 -0
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/features/roles/roles/roles.ts +184 -0
- package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
- package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/features/settings/settings/settings.ts +64 -0
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/features/tags/tags/tags.ts +80 -0
- package/template/templates/mithaq/components.json +22 -0
- 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/mithaq/src/mithaq/audit-sink.ts +111 -0
- package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
- 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/mithaq/src/mithaq/pages.ts +83 -0
- 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/mithaq/src/roles/grants.ts +47 -0
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/mithaq/src/roles/statements.ts +13 -0
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
- package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
- package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/mithaq/src/storage/defaults.ts +156 -0
- package/template/templates/mithaq/src/storage/index.ts +97 -0
- package/template/templates/mithaq/src/storage/local.ts +143 -0
- package/template/templates/mithaq/src/storage/r2.ts +5 -0
- package/template/templates/mithaq/src/storage/s3.ts +91 -0
- package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
- package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
- package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
- package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
- package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
- package/README.md +0 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The portal's catalogue: the courses on offer as cards, searched by title and summary and narrowed by tag, each
|
|
2
|
+
// card opening the course's page.
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Search } from 'lucide-react'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
6
|
+
import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
7
|
+
import { Input } from '@/components/ui/input'
|
|
8
|
+
import { Toggle } from '@/components/ui/toggle'
|
|
9
|
+
import { PortalShell, display } from './shell.tsx'
|
|
10
|
+
import { CardsLoading, CourseCard, ReadFailed } from './parts.tsx'
|
|
11
|
+
import { inLocale } from './rules.ts'
|
|
12
|
+
import { catalogueOf } from './reads.ts'
|
|
13
|
+
|
|
14
|
+
/** Drawn whole as the courses' list page, so it reads the list itself. */
|
|
15
|
+
export function Catalogue() {
|
|
16
|
+
const { locale, t } = useLocale()
|
|
17
|
+
const client = useClient()
|
|
18
|
+
const screen = useScreen()
|
|
19
|
+
const read = useQuery({ queryKey: ['portal-catalogue'], queryFn: () => catalogueOf({ client, maxLimit: screen.maxLimit, now: Date.now() }), staleTime: 30_000 })
|
|
20
|
+
const [term, setTerm] = useState('')
|
|
21
|
+
const [tag, setTag] = useState<string>()
|
|
22
|
+
const found = read.data
|
|
23
|
+
const asked = term.trim().toLocaleLowerCase(locale)
|
|
24
|
+
const shown = found?.courses.filter((course) =>
|
|
25
|
+
(!tag || (Array.isArray(course.tags) && course.tags.includes(tag)))
|
|
26
|
+
&& (!asked || [course.title, course.summary].some((one) => typeof one === 'string' && one.toLocaleLowerCase(locale).includes(asked))))
|
|
27
|
+
const title = t('courses.plural', 'Courses')
|
|
28
|
+
return (
|
|
29
|
+
<PortalShell
|
|
30
|
+
regions={{
|
|
31
|
+
header: <h1 className={`${display} text-3xl`}>{title}</h1>,
|
|
32
|
+
main: (
|
|
33
|
+
<div className="grid gap-5">
|
|
34
|
+
<div className="grid gap-3">
|
|
35
|
+
<label className="border-input bg-card focus-within:ring-ring flex h-12 items-center gap-2.5 rounded-full border px-4 focus-within:ring-2">
|
|
36
|
+
<Search aria-hidden className="text-muted-foreground size-4.5 shrink-0" />
|
|
37
|
+
<span className="sr-only">{t('portal.search', 'Search courses')}</span>
|
|
38
|
+
<Input
|
|
39
|
+
type="search"
|
|
40
|
+
value={term}
|
|
41
|
+
onChange={(event) => setTerm(event.target.value)}
|
|
42
|
+
placeholder={t('portal.search', 'Search courses')}
|
|
43
|
+
className="h-auto border-0 bg-transparent p-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent"
|
|
44
|
+
/>
|
|
45
|
+
</label>
|
|
46
|
+
{found?.tags.size ? (
|
|
47
|
+
<div role="group" aria-label={t('portal.byTag', 'Filter by tag')} className="flex flex-wrap gap-2">
|
|
48
|
+
<Toggle variant="outline" pressed={!tag} onPressedChange={() => setTag(undefined)} className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4">
|
|
49
|
+
{t('portal.allTags', 'All')}
|
|
50
|
+
</Toggle>
|
|
51
|
+
{[...found.tags.values()].map((one) => (
|
|
52
|
+
<Toggle
|
|
53
|
+
key={String(one.id)}
|
|
54
|
+
variant="outline"
|
|
55
|
+
pressed={tag === one.id}
|
|
56
|
+
onPressedChange={(pressed) => setTag(pressed ? String(one.id) : undefined)}
|
|
57
|
+
className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4"
|
|
58
|
+
>
|
|
59
|
+
{inLocale(one.name, locale)}
|
|
60
|
+
</Toggle>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
{read.isError ? <ReadFailed retry={() => void read.refetch()} /> : !found || !shown ? <CardsLoading /> : shown.length ? (
|
|
66
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-5">
|
|
67
|
+
{shown.map((course) => (
|
|
68
|
+
<CourseCard
|
|
69
|
+
key={String(course.id)}
|
|
70
|
+
course={course}
|
|
71
|
+
cover={found.covers.get(String(course.cover))}
|
|
72
|
+
next={found.next.get(String(course.id))}
|
|
73
|
+
tags={found.tags}
|
|
74
|
+
href={`${screen.path}/courses/${encodeURIComponent(String(course.id))}`}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<p className="text-muted-foreground text-sm">{found.courses.length ? t('portal.noMatch', 'No course matches.') : t('courses.noneOnOffer', 'No courses on offer yet.')}</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
),
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// A course's page in the portal: its cover, what it is and costs, what it covers, and its sessions still to come.
|
|
2
|
+
// Where the course lets its students enroll themselves, a student picks a session with a seat left and takes it;
|
|
3
|
+
// where it does not, the page says the school enrolls them. Who may take a seat, and whether one is left, the
|
|
4
|
+
// server decides: this page only offers what it would admit.
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { ArrowLeft, CalendarDays, MapPin } from 'lucide-react'
|
|
7
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
8
|
+
import type { Row } from 'mithaq/admin'
|
|
9
|
+
import { holds } from 'mithaq/admin'
|
|
10
|
+
import { useAccess, useClient, useLocale, useRecord, useScreen, useUser } from 'mithaq/admin/react'
|
|
11
|
+
import { RichTextValueView } from 'mithaq-admin/rich-text-viewer.tsx'
|
|
12
|
+
import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
|
|
13
|
+
import { toast } from 'mithaq-admin/toast.tsx'
|
|
14
|
+
import { Button } from '@/components/ui/button'
|
|
15
|
+
import { DirectionalIcon } from '@/components/ui/directional-icon'
|
|
16
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
17
|
+
import { PortalShell, display } from './shell.tsx'
|
|
18
|
+
import { Cover, Tags, useOption, usePlace } from './parts.tsx'
|
|
19
|
+
import { feeOf, seatsOf, whenOf } from './rules.ts'
|
|
20
|
+
import { byIds, readSeats } from './reads.ts'
|
|
21
|
+
|
|
22
|
+
type Session = Row & { seatsLeft?: number | null }
|
|
23
|
+
|
|
24
|
+
export function Course() {
|
|
25
|
+
const { locale, t } = useLocale()
|
|
26
|
+
const client = useClient()
|
|
27
|
+
const screen = useScreen()
|
|
28
|
+
const access = useAccess()
|
|
29
|
+
const user = useUser()
|
|
30
|
+
const record = useRecord()
|
|
31
|
+
const option = useOption()
|
|
32
|
+
const place = usePlace()
|
|
33
|
+
const queries = useQueryClient()
|
|
34
|
+
const id = String(record?.id ?? '')
|
|
35
|
+
// The course with its sessions and the seats each has left, which a student, who reads its own seats alone,
|
|
36
|
+
// could not count; then its cover and tags, and the viewer's own seats in it.
|
|
37
|
+
const read = useQuery({
|
|
38
|
+
queryKey: ['portal-course', id],
|
|
39
|
+
queryFn: async () => {
|
|
40
|
+
const course = (await client({ kayan: 'courses', operation: 'findWithSessions', input: { id } })) as Row & { sessions: Session[] }
|
|
41
|
+
const [covers, tags, mine] = await Promise.all([
|
|
42
|
+
byIds({ client, maxLimit: screen.maxLimit, kayan: 'media', ids: typeof course.cover === 'string' ? [course.cover] : [] }),
|
|
43
|
+
byIds({ client, maxLimit: screen.maxLimit, kayan: 'tags', ids: Array.isArray(course.tags) ? course.tags.map(String) : [] }),
|
|
44
|
+
user.id && course.sessions.length ? readSeats({ client, maxLimit: screen.maxLimit, where: { and: [{ student: { equals: user.id } }, { session: { in: course.sessions.map((one) => one.id) } }] } }) : [],
|
|
45
|
+
])
|
|
46
|
+
return { course, cover: covers.get(String(course.cover)), tags, mine }
|
|
47
|
+
},
|
|
48
|
+
enabled: !!id,
|
|
49
|
+
})
|
|
50
|
+
const [picked, setPicked] = useState<string>()
|
|
51
|
+
const [busy, setBusy] = useState(false)
|
|
52
|
+
const course = read.data?.course ?? record ?? {}
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
// A session still to come and open; one archived takes no seat, so it is not offered.
|
|
55
|
+
const sessions = (course.archived === true ? [] : read.data?.course.sessions ?? [])
|
|
56
|
+
.filter((one) => one.archived !== true && new Date(one.startsAt as string).getTime() > now)
|
|
57
|
+
.sort((a, b) => new Date(a.startsAt as string).getTime() - new Date(b.startsAt as string).getTime())
|
|
58
|
+
// The viewer's own seat in a session of this course still to come, which it already holds.
|
|
59
|
+
const held = read.data?.mine.find((one) => new Date(one.startsAt as string).getTime() > now)
|
|
60
|
+
// A student takes its own seat; whoever reads every seat enrolls others from the enrollments' own page.
|
|
61
|
+
const self = holds(access, 'enrollments.create') && !holds(access, 'enrollments.others')
|
|
62
|
+
// Whoever runs the catalogue reads an archived course too; the portal offers none of its seats.
|
|
63
|
+
const open = course.selfEnroll === true && course.archived !== true
|
|
64
|
+
const chosen = sessions.find((one) => one.id === picked && one.seatsLeft !== 0)
|
|
65
|
+
const fee = feeOf({ fee: course.fee, currency: course.currency, locale, t })
|
|
66
|
+
const enroll = async () => {
|
|
67
|
+
if (!chosen) return
|
|
68
|
+
setBusy(true)
|
|
69
|
+
try {
|
|
70
|
+
await client({ kayan: 'enrollments', operation: 'create', input: { session: chosen.id } })
|
|
71
|
+
toast.success(t('portal.enrolled', 'You are enrolled.')!)
|
|
72
|
+
await queries.invalidateQueries()
|
|
73
|
+
} catch (thrown) {
|
|
74
|
+
const key = refusalKeyOf(thrown)
|
|
75
|
+
toast.error((key && t(key)) || t('portal.enrollFailed', 'The seat was not taken. Please try again.')!)
|
|
76
|
+
await queries.invalidateQueries({ queryKey: ['portal-course', id] })
|
|
77
|
+
} finally {
|
|
78
|
+
setBusy(false)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const list = `${screen.path}/courses`
|
|
82
|
+
return (
|
|
83
|
+
<PortalShell
|
|
84
|
+
regions={{
|
|
85
|
+
header: (
|
|
86
|
+
<div className="grid gap-5">
|
|
87
|
+
<div className="relative -mx-4 overflow-hidden sm:mx-0 sm:rounded-3xl">
|
|
88
|
+
<Cover course={course} cover={read.data?.cover} className="h-56 w-full sm:h-72" />
|
|
89
|
+
<a href={list} aria-label={t('portal.back', 'Back to courses')} className="bg-card text-foreground focus-visible:ring-ring absolute start-4 top-4 grid size-11 place-items-center rounded-full shadow focus-visible:ring-2 focus-visible:outline-none">
|
|
90
|
+
<DirectionalIcon><ArrowLeft aria-hidden className="size-5" /></DirectionalIcon>
|
|
91
|
+
</a>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="grid gap-2.5">
|
|
94
|
+
{read.data ? <Tags course={course} tags={read.data.tags} /> : null}
|
|
95
|
+
<h1 className={`${display} text-3xl leading-tight`}>{String(course.title ?? '')}</h1>
|
|
96
|
+
<p className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
|
97
|
+
{fee ? <span><bdi>{fee}</bdi> {course.fee ? t('portal.perSeat', 'per seat') : null}</span> : null}
|
|
98
|
+
{option('courses', 'delivery', course.delivery) ? <span>{option('courses', 'delivery', course.delivery)}</span> : null}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
main: (
|
|
104
|
+
<div className="grid gap-8 pb-24 md:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] md:pb-0">
|
|
105
|
+
<section aria-labelledby="about" className="grid content-start gap-3">
|
|
106
|
+
<h2 id="about" className="text-lg font-semibold">{t('portal.aboutCourse', 'About this course')}</h2>
|
|
107
|
+
{typeof course.summary === 'string' && course.summary ? <p className="leading-relaxed">{course.summary}</p> : null}
|
|
108
|
+
{course.details ? <RichTextValueView value={course.details} t={t} /> : null}
|
|
109
|
+
</section>
|
|
110
|
+
<section aria-labelledby="sessions" className="grid content-start gap-3">
|
|
111
|
+
<h2 id="sessions" className="text-lg font-semibold">{self && open && !held ? t('portal.choose', 'Choose a session') : t('sessions.plural', 'Sessions')}</h2>
|
|
112
|
+
{!read.data && !read.isError ? <Skeleton className="h-20 rounded-2xl" /> : null}
|
|
113
|
+
{read.data && !sessions.length ? <p className="text-muted-foreground text-sm">{t('portal.noSession', 'No session scheduled yet')}</p> : null}
|
|
114
|
+
{self && open && !held ? (
|
|
115
|
+
<div role="radiogroup" aria-labelledby="sessions" className="grid gap-2.5">
|
|
116
|
+
{sessions.map((one) => {
|
|
117
|
+
const full = one.seatsLeft === 0
|
|
118
|
+
return (
|
|
119
|
+
<label key={String(one.id)} className={`bg-card flex items-center gap-3 rounded-2xl border-2 px-4 py-3.5 ${picked === one.id ? 'border-primary' : 'border-transparent'} ${full ? 'opacity-60' : 'cursor-pointer'}`}>
|
|
120
|
+
<input type="radio" name="session" value={String(one.id)} disabled={full} checked={picked === one.id} onChange={() => setPicked(String(one.id))} className="accent-primary size-5" />
|
|
121
|
+
<SessionLine session={one} place={place(one)} />
|
|
122
|
+
<span className={`shrink-0 text-sm ${full ? 'text-muted-foreground' : 'text-primary'}`}>{seatsOf({ seatsLeft: one.seatsLeft, locale, t })}</span>
|
|
123
|
+
</label>
|
|
124
|
+
)
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
) : (
|
|
128
|
+
<ul className="grid gap-2.5">
|
|
129
|
+
{sessions.map((one) => (
|
|
130
|
+
<li key={String(one.id)} className="bg-card flex items-center gap-3 rounded-2xl px-4 py-3.5">
|
|
131
|
+
<SessionLine session={one} place={place(one)} />
|
|
132
|
+
<span className="text-muted-foreground shrink-0 text-sm">{seatsOf({ seatsLeft: one.seatsLeft, locale, t })}</span>
|
|
133
|
+
</li>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
{self && open && !held && sessions.length ? (
|
|
138
|
+
// At the foot of a phone, over the tab bar, so the button stays in reach; in the flow from md up.
|
|
139
|
+
<div className="bg-card fixed inset-x-0 bottom-[calc(4.25rem+max(env(safe-area-inset-bottom),0.75rem))] z-10 flex items-center gap-3 border-t px-4 py-3 md:static md:rounded-2xl md:border">
|
|
140
|
+
{fee ? (
|
|
141
|
+
<span className="grid shrink-0">
|
|
142
|
+
<span className="text-muted-foreground text-xs">{t('portal.total', 'Total')}</span>
|
|
143
|
+
<span className="text-lg font-semibold"><bdi>{fee}</bdi></span>
|
|
144
|
+
</span>
|
|
145
|
+
) : null}
|
|
146
|
+
<Button type="button" size="lg" className="h-12 flex-1 rounded-full text-base" disabled={!chosen || busy} onClick={() => void enroll()}>
|
|
147
|
+
{chosen ? t('portal.enrollIn', 'Enroll on {when}')!.replace('{when}', () => whenOf({ value: chosen.startsAt, locale }) ?? '') : t('portal.pick', 'Pick a session')}
|
|
148
|
+
</Button>
|
|
149
|
+
</div>
|
|
150
|
+
) : null}
|
|
151
|
+
{held ? (
|
|
152
|
+
<p className="bg-accent text-accent-foreground rounded-2xl px-4 py-3 text-sm">
|
|
153
|
+
{t('portal.holding', 'You hold a seat on {when}.')!.replace('{when}', () => whenOf({ value: held.startsAt, locale }) ?? '')}{' '}
|
|
154
|
+
<a href={`${screen.path}/enrollments`} className="font-semibold underline-offset-4 hover:underline">{t('portal.myEnrollments', 'My enrollments')}</a>
|
|
155
|
+
</p>
|
|
156
|
+
) : self && !open && read.data ? (
|
|
157
|
+
<p className="bg-accent text-accent-foreground rounded-2xl px-4 py-3 text-sm">{t('portal.askSchool', 'Ask your school to enroll you.')}</p>
|
|
158
|
+
) : null}
|
|
159
|
+
</section>
|
|
160
|
+
</div>
|
|
161
|
+
),
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A session as a line: when it meets, and where and how. */
|
|
168
|
+
function SessionLine({ session, place }: { session: Row; place: string }) {
|
|
169
|
+
const { locale } = useLocale()
|
|
170
|
+
return (
|
|
171
|
+
<span className="grid min-w-0 flex-1 gap-0.5">
|
|
172
|
+
<span className="flex items-center gap-1.5 font-semibold"><CalendarDays aria-hidden className="size-4 shrink-0" /><span>{whenOf({ value: session.startsAt, locale })}</span></span>
|
|
173
|
+
{place ? <span className="text-muted-foreground flex items-center gap-1.5 text-sm"><MapPin aria-hidden className="size-4 shrink-0" />{place}</span> : null}
|
|
174
|
+
</span>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The portal's enrollments, upcoming first. A student reads its own seats as cards: the course's title and cover,
|
|
2
|
+
// when the session meets, where and how, and, before it starts, the way to give the seat back. Whoever reads every
|
|
3
|
+
// seat, an instructor, reads them as a table with each seat's student, named only where the server lets it read
|
|
4
|
+
// that person.
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
7
|
+
import { holds } from 'mithaq/admin'
|
|
8
|
+
import { useAccess, useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
9
|
+
import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
|
|
10
|
+
import { toast } from 'mithaq-admin/toast.tsx'
|
|
11
|
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
|
12
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
13
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
14
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
15
|
+
import { PortalShell, display } from './shell.tsx'
|
|
16
|
+
import { CardsLoading, Cover, ReadFailed, usePlace } from './parts.tsx'
|
|
17
|
+
import { byWhen, startsIn, whenOf } from './rules.ts'
|
|
18
|
+
import { readSeats, type Seat } from './reads.ts'
|
|
19
|
+
|
|
20
|
+
export function Enrollments() {
|
|
21
|
+
const { t } = useLocale()
|
|
22
|
+
const client = useClient()
|
|
23
|
+
const screen = useScreen()
|
|
24
|
+
const access = useAccess()
|
|
25
|
+
const others = holds(access, 'enrollments.others')
|
|
26
|
+
// A student is never asked for people, whom it reads none of.
|
|
27
|
+
const people = others && access.collections?.enrollments?.fields?.student?.read === true
|
|
28
|
+
const read = useQuery({ queryKey: ['portal-enrollments', people], queryFn: () => readSeats({ client, maxLimit: screen.maxLimit, people }) })
|
|
29
|
+
const [when, setWhen] = useState<'upcoming' | 'past'>('upcoming')
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
const split = read.data && byWhen(read.data, now)
|
|
32
|
+
const shown = split?.[when] ?? []
|
|
33
|
+
const title = others ? t('enrollments.plural', 'Enrollments') : t('portal.myEnrollments', 'My enrollments')
|
|
34
|
+
const creates = others && holds(access, 'enrollments.create')
|
|
35
|
+
return (
|
|
36
|
+
<PortalShell
|
|
37
|
+
regions={{
|
|
38
|
+
header: (
|
|
39
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
40
|
+
<h1 className={`${display} text-3xl`}>{title}</h1>
|
|
41
|
+
{creates ? <a href={`${screen.path}/enrollments/new`} className={buttonVariants({ className: 'h-11 rounded-full px-5' })}>{t('enrollments.new', 'New enrollment')}</a> : null}
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
main: (
|
|
45
|
+
<div className="grid gap-5">
|
|
46
|
+
<Tabs value={when} onValueChange={(value) => setWhen(value as 'upcoming' | 'past')}>
|
|
47
|
+
<TabsList className="h-11 w-full max-w-sm">
|
|
48
|
+
<TabsTrigger value="upcoming" className="h-9 flex-1">{t('portal.upcoming', 'Upcoming')}</TabsTrigger>
|
|
49
|
+
<TabsTrigger value="past" className="h-9 flex-1">{t('portal.past', 'Past')}</TabsTrigger>
|
|
50
|
+
</TabsList>
|
|
51
|
+
</Tabs>
|
|
52
|
+
{read.isError ? <ReadFailed retry={() => void read.refetch()} /> : !split ? <CardsLoading count={2} /> : !shown.length ? (
|
|
53
|
+
<p className="text-muted-foreground text-sm">{when === 'upcoming' ? t('portal.noneUpcoming', 'No session to come.') : t('portal.nonePast', 'No past session.')}</p>
|
|
54
|
+
) : others ? (
|
|
55
|
+
<SeatTable seats={shown} people={people} />
|
|
56
|
+
) : (
|
|
57
|
+
<ul className="grid gap-3.5">
|
|
58
|
+
{shown.map((one, at) => <SeatCard key={String(one.seat.id)} seat={one} first={when === 'upcoming' && at === 0} now={now} canLeave={when === 'upcoming' && holds(access, 'enrollments.delete')} />)}
|
|
59
|
+
</ul>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
),
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One seat of the viewer's own as a card, the soonest one marked. */
|
|
69
|
+
function SeatCard({ seat, first, now, canLeave }: { seat: Seat; first: boolean; now: number; canLeave: boolean }) {
|
|
70
|
+
const { locale, t } = useLocale()
|
|
71
|
+
const screen = useScreen()
|
|
72
|
+
const place = usePlace()
|
|
73
|
+
const soon = startsIn({ value: seat.startsAt, now, locale })
|
|
74
|
+
const where = place(seat.session)
|
|
75
|
+
return (
|
|
76
|
+
<li className="bg-card overflow-hidden rounded-2xl">
|
|
77
|
+
<div className="flex gap-3.5 p-4">
|
|
78
|
+
<Cover course={seat.course} cover={seat.cover} className="size-18 shrink-0 rounded-xl" />
|
|
79
|
+
<div className="grid min-w-0 content-start gap-1">
|
|
80
|
+
<h2 className="text-[17px] leading-snug font-semibold">{String(seat.course?.title ?? t('admin.notAvailable', 'Not available'))}</h2>
|
|
81
|
+
<p className="text-sm">{whenOf({ value: seat.startsAt, locale })}</p>
|
|
82
|
+
{where ? <p className="text-muted-foreground text-sm">{where}</p> : null}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div className={`flex flex-wrap items-center justify-between gap-3 px-4 py-3 ${first ? 'bg-accent' : 'border-t'}`}>
|
|
86
|
+
<span className={`text-sm ${first ? 'text-accent-foreground font-semibold' : 'text-muted-foreground'}`}>{soon ? t('portal.startsIn', 'Starts {when}')!.replace('{when}', () => soon) : null}</span>
|
|
87
|
+
<div className="flex gap-2">
|
|
88
|
+
{canLeave ? <Leave seat={seat} /> : null}
|
|
89
|
+
{seat.course ? (
|
|
90
|
+
<a href={`${screen.path}/courses/${encodeURIComponent(String(seat.course.id))}`} className={buttonVariants({ className: 'h-11 rounded-full px-4' })}>{t('portal.course', 'Course')}</a>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</li>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Gives a seat back before its session starts, behind a question, since the seat goes to whoever takes it next. */
|
|
99
|
+
function Leave({ seat }: { seat: Seat }) {
|
|
100
|
+
const { t } = useLocale()
|
|
101
|
+
const client = useClient()
|
|
102
|
+
const queries = useQueryClient()
|
|
103
|
+
const [asking, setAsking] = useState(false)
|
|
104
|
+
const leave = async () => {
|
|
105
|
+
try {
|
|
106
|
+
await client({ kayan: 'enrollments', operation: 'delete', input: { id: seat.seat.id } })
|
|
107
|
+
toast.success(t('portal.left', 'Your enrollment is cancelled.')!)
|
|
108
|
+
} catch (thrown) {
|
|
109
|
+
const key = refusalKeyOf(thrown)
|
|
110
|
+
toast.error((key && t(key)) || t('portal.leaveFailed', 'The enrollment was not cancelled. Please try again.')!)
|
|
111
|
+
}
|
|
112
|
+
await queries.invalidateQueries()
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<Button type="button" variant="ghost" className="text-destructive h-11 rounded-full px-4" onClick={() => setAsking(true)}>{t('portal.leave', 'Cancel enrollment')}</Button>
|
|
117
|
+
<AlertDialog open={asking} onOpenChange={setAsking}>
|
|
118
|
+
<AlertDialogContent>
|
|
119
|
+
<AlertDialogHeader>
|
|
120
|
+
<AlertDialogTitle>{t('portal.leaveTitle', 'Cancel this enrollment?')}</AlertDialogTitle>
|
|
121
|
+
<AlertDialogDescription>{t('portal.leaveBody', 'Your seat goes back to the session, and someone else may take it.')}</AlertDialogDescription>
|
|
122
|
+
</AlertDialogHeader>
|
|
123
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
124
|
+
<AlertDialogCancel>{t('portal.keep', 'Keep it')}</AlertDialogCancel>
|
|
125
|
+
<AlertDialogAction variant="destructive" onClick={() => {
|
|
126
|
+
setAsking(false)
|
|
127
|
+
void leave()
|
|
128
|
+
}}>{t('portal.leave', 'Cancel enrollment')}</AlertDialogAction>
|
|
129
|
+
</AlertDialogFooter>
|
|
130
|
+
</AlertDialogContent>
|
|
131
|
+
</AlertDialog>
|
|
132
|
+
</>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every seat the viewer reads as a table, with each seat's student where the viewer reads people. */
|
|
137
|
+
function SeatTable({ seats, people }: { seats: readonly Seat[]; people: boolean }) {
|
|
138
|
+
const { locale, t } = useLocale()
|
|
139
|
+
const screen = useScreen()
|
|
140
|
+
const place = usePlace()
|
|
141
|
+
return (
|
|
142
|
+
<div className="bg-card overflow-x-auto rounded-2xl">
|
|
143
|
+
<Table>
|
|
144
|
+
<TableHeader>
|
|
145
|
+
<TableRow>
|
|
146
|
+
<TableHead>{t('courses.singular', 'Course')}</TableHead>
|
|
147
|
+
<TableHead>{t('sessions.singular', 'Session')}</TableHead>
|
|
148
|
+
<TableHead>{t('portal.place', 'Venue and format')}</TableHead>
|
|
149
|
+
{people ? <TableHead>{t('enrollments.student', 'Student')}</TableHead> : null}
|
|
150
|
+
</TableRow>
|
|
151
|
+
</TableHeader>
|
|
152
|
+
<TableBody>
|
|
153
|
+
{seats.map((one) => (
|
|
154
|
+
<TableRow key={String(one.seat.id)}>
|
|
155
|
+
<TableCell>
|
|
156
|
+
<a href={`${screen.path}/enrollments/${encodeURIComponent(String(one.seat.id))}`} className="flex items-center gap-2.5 font-medium underline-offset-4 hover:underline">
|
|
157
|
+
<Cover course={one.course} cover={one.cover} className="size-9 shrink-0 rounded-lg" />
|
|
158
|
+
{String(one.course?.title ?? t('admin.notAvailable', 'Not available'))}
|
|
159
|
+
</a>
|
|
160
|
+
</TableCell>
|
|
161
|
+
<TableCell className="whitespace-nowrap">{whenOf({ value: one.startsAt, locale })}</TableCell>
|
|
162
|
+
<TableCell>{place(one.session)}</TableCell>
|
|
163
|
+
{people ? <TableCell>{typeof one.student?.name === 'string' ? one.student.name : t('admin.notAvailable', 'Not available')}</TableCell> : null}
|
|
164
|
+
</TableRow>
|
|
165
|
+
))}
|
|
166
|
+
</TableBody>
|
|
167
|
+
</Table>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// What the portal's pages draw alike: a course's cover, its tags, its card, and a session's place.
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import type { Row } from 'mithaq/admin'
|
|
4
|
+
import { useLocale, useScreen } from 'mithaq/admin/react'
|
|
5
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
6
|
+
import { display } from './shell.tsx'
|
|
7
|
+
import { feeOf, inLocale, whenOf } from './rules.ts'
|
|
8
|
+
|
|
9
|
+
/** A text field's option in the viewer's words, as a choice reads it on every screen, and the stored value where none is declared. */
|
|
10
|
+
export const useOption = () => {
|
|
11
|
+
const { locale } = useLocale()
|
|
12
|
+
const screen = useScreen()
|
|
13
|
+
return (kayan: string, field: string, value: unknown) => {
|
|
14
|
+
if (typeof value !== 'string' || !value) return undefined
|
|
15
|
+
const option = screen.kayan[kayan]?.fields.find((one) => one.name === field)?.options?.find((one) => one.value === value)
|
|
16
|
+
return inLocale(option?.label, locale) ?? value
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where and how a session meets, each part the viewer reads: "Campus · Workshop". */
|
|
21
|
+
export const usePlace = () => {
|
|
22
|
+
const option = useOption()
|
|
23
|
+
return (session?: Row) => [option('sessions', 'venue', session?.venue), option('sessions', 'format', session?.format)].filter(Boolean).join(' · ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Each cover that has no image is one of the portal's own colors, the same one for a course every time. */
|
|
27
|
+
const grounds = ['bg-primary', 'bg-[var(--portal-amber)]', 'bg-[#9CC9C5] dark:bg-[#2F5654]']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A course's cover: its image through the file route, which the image's own access guards, or a ground of the
|
|
31
|
+
* portal's colors. It says nothing a screen reader needs, since the course's title is beside it.
|
|
32
|
+
*/
|
|
33
|
+
export function Cover({ course, cover, className }: { course?: Row; cover?: Row; className: string }) {
|
|
34
|
+
const url = typeof cover?.url === 'string' ? cover.url : undefined
|
|
35
|
+
if (url) return <img src={url} alt="" loading="lazy" decoding="async" className={`object-cover ${className}`} />
|
|
36
|
+
const at = [...String(course?.id ?? '')].reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % grounds.length
|
|
37
|
+
return <div aria-hidden className={`${grounds[at]} ${className}`} />
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A course's tags, each by its name in the viewer's locale; a tag it may not read is left out. */
|
|
41
|
+
export function Tags({ course, tags }: { course: Row; tags: ReadonlyMap<string, Row> }) {
|
|
42
|
+
const { locale } = useLocale()
|
|
43
|
+
const names = (Array.isArray(course.tags) ? course.tags : []).flatMap((id) => {
|
|
44
|
+
const name = inLocale(tags.get(String(id))?.name, locale)
|
|
45
|
+
return name ? [name] : []
|
|
46
|
+
})
|
|
47
|
+
if (!names.length) return null
|
|
48
|
+
return (
|
|
49
|
+
<ul className="flex flex-wrap gap-1.5">
|
|
50
|
+
{names.map((name) => <li key={name} className="bg-accent text-accent-foreground rounded-full px-2.5 py-0.5 text-xs">{name}</li>)}
|
|
51
|
+
</ul>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One course on offer: its cover, tags, title, summary, next session and fee, all one link to its page. */
|
|
56
|
+
export function CourseCard({ course, cover, next, tags, href }: { course: Row; cover?: Row; next?: Row; tags: ReadonlyMap<string, Row>; href: string }) {
|
|
57
|
+
const { locale, t } = useLocale()
|
|
58
|
+
const fee = feeOf({ fee: course.fee, currency: course.currency, locale, t })
|
|
59
|
+
const when = whenOf({ value: next?.startsAt, locale })
|
|
60
|
+
return (
|
|
61
|
+
<a href={href} className="bg-card text-card-foreground focus-visible:ring-ring flex flex-col overflow-hidden rounded-2xl transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:outline-none">
|
|
62
|
+
<Cover course={course} cover={cover} className="h-36 w-full" />
|
|
63
|
+
<div className="flex flex-1 flex-col gap-2 p-4">
|
|
64
|
+
<Tags course={course} tags={tags} />
|
|
65
|
+
<h2 className={`${display} text-lg leading-snug`}>{String(course.title ?? '')}</h2>
|
|
66
|
+
{typeof course.summary === 'string' && course.summary ? <p className="text-muted-foreground line-clamp-2 text-sm leading-relaxed">{course.summary}</p> : null}
|
|
67
|
+
<div className="mt-auto flex items-center justify-between gap-3 pt-1 text-sm">
|
|
68
|
+
<span className="text-muted-foreground">{when ? `${t('portal.next', 'Next session')} · ${when}` : t('portal.noSession', 'No session scheduled yet')}</span>
|
|
69
|
+
{fee ? <span className="text-primary shrink-0 font-semibold"><bdi>{fee}</bdi></span> : null}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</a>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What stands where cards are still being read, the same shape so nothing jumps when they arrive. */
|
|
77
|
+
export function CardsLoading({ count = 3 }: { count?: number }) {
|
|
78
|
+
const { t } = useLocale()
|
|
79
|
+
return (
|
|
80
|
+
<div role="status" aria-label={t('admin.loading', 'Loading…')} className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-5">
|
|
81
|
+
{Array.from({ length: count }, (_, at) => <Skeleton key={at} className="h-72 rounded-2xl" />)}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A failed read, said once, with the way to try again. */
|
|
87
|
+
export function ReadFailed({ retry }: { retry: () => void }): ReactNode {
|
|
88
|
+
const { t } = useLocale()
|
|
89
|
+
return (
|
|
90
|
+
<p role="alert" className="text-muted-foreground text-sm">
|
|
91
|
+
{t('portal.failed', 'This could not be read just now.')}{' '}
|
|
92
|
+
<button type="button" onClick={retry} className="text-primary font-medium underline-offset-4 hover:underline">{t('portal.retry', 'Try again')}</button>
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What the portal's pages read, each read the viewer's own through the screen's client, so a row the viewer may
|
|
2
|
+
// not read is left out of what comes back and a page draws what it can.
|
|
3
|
+
import type { FetchClient, Row } from 'mithaq/admin'
|
|
4
|
+
import { onOffer } from '../../domain/on-offer.ts'
|
|
5
|
+
import { nextByCourse } from './rules.ts'
|
|
6
|
+
|
|
7
|
+
type Reads = { client: FetchClient; maxLimit: number }
|
|
8
|
+
type Found = { docs: Row[]; totalDocs: number }
|
|
9
|
+
|
|
10
|
+
const find = ({ client, kayan, input }: { client: FetchClient; kayan: string; input: Record<string, unknown> }) =>
|
|
11
|
+
client({ kayan, operation: 'find', input }) as Promise<Found>
|
|
12
|
+
|
|
13
|
+
/** A read the viewer may not make is an empty one; anything else failed and throws, for the query to try again. */
|
|
14
|
+
const unread = (thrown: unknown): never[] => {
|
|
15
|
+
const key = (thrown as { data?: { key?: unknown } } | null)?.data?.key
|
|
16
|
+
if (typeof key === 'string' && key.startsWith('error.forbidden')) return []
|
|
17
|
+
throw thrown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const idsOf = (rows: readonly Row[], field: string) =>
|
|
21
|
+
[...new Set(rows.flatMap((one) => { const value = one[field]; return Array.isArray(value) ? value : [value] }).filter((id): id is string => typeof id === 'string'))].sort()
|
|
22
|
+
|
|
23
|
+
/** Some rows of one kayan by id, as the viewer reads them, a page of the build's cap at a time. */
|
|
24
|
+
export const byIds = async ({ client, kayan, ids, maxLimit }: Reads & { kayan: string; ids: readonly string[] }): Promise<Map<string, Row>> => {
|
|
25
|
+
const rows: Row[] = []
|
|
26
|
+
for (let at = 0; at < ids.length; at += maxLimit) {
|
|
27
|
+
const piece = ids.slice(at, at + maxLimit)
|
|
28
|
+
rows.push(...(await find({ client, kayan, input: { where: { id: { in: piece } }, limit: piece.length } }).then((found) => found.docs, unread)))
|
|
29
|
+
}
|
|
30
|
+
return new Map(rows.map((one) => [String(one.id), one]))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Catalogue = { courses: Row[]; next: Map<string, Row>; covers: Map<string, Row>; tags: Map<string, Row> }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The courses on offer, published and not archived, by title, each with its next session, its cover and its
|
|
37
|
+
* tags. A plain read answers the published courses alone, whoever reads.
|
|
38
|
+
*/
|
|
39
|
+
export const catalogueOf = async ({ client, maxLimit, now }: Reads & { now: number }): Promise<Catalogue> => {
|
|
40
|
+
// ponytail: one page of the build's cap, searched and filtered in the browser; a server search and pages once a
|
|
41
|
+
// school offers more courses than that.
|
|
42
|
+
const courses = (await find({ client, kayan: 'courses', input: { where: onOffer, sort: 'title', limit: maxLimit } })).docs
|
|
43
|
+
const ids = idsOf(courses, 'id')
|
|
44
|
+
const [sessions, covers, tags] = await Promise.all([
|
|
45
|
+
ids.length
|
|
46
|
+
? find({ client, kayan: 'sessions', input: { where: { and: [{ course: { in: ids } }, { startsAt: { greater_than: new Date(now).toISOString() } }] }, sort: 'startsAt', limit: maxLimit } }).then((found) => found.docs, unread)
|
|
47
|
+
: [],
|
|
48
|
+
byIds({ client, maxLimit, kayan: 'media', ids: idsOf(courses, 'cover') }),
|
|
49
|
+
byIds({ client, maxLimit, kayan: 'tags', ids: idsOf(courses, 'tags') }),
|
|
50
|
+
])
|
|
51
|
+
return { courses, next: nextByCourse(sessions, now), covers, tags }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One seat as the portal draws it: its session, the session's course and that course's cover, and its student. */
|
|
55
|
+
export type Seat = { seat: Row; startsAt?: unknown; session?: Row; course?: Row; cover?: Row; student?: Row }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The seats the viewer reads, the newest first, each with what it draws. `where` narrows them, as the home does to
|
|
59
|
+
* the viewer's own. People are read only where `people` is set, and each is one the viewer may read: a name the
|
|
60
|
+
* server keeps from it is left out.
|
|
61
|
+
*/
|
|
62
|
+
export const readSeats = async ({ client, maxLimit, where, people = false }: Reads & { where?: Row; people?: boolean }): Promise<Seat[]> => {
|
|
63
|
+
// ponytail: one page of the build's cap, the newest; pages once a viewer holds more seats than that.
|
|
64
|
+
const seats = (await find({ client, kayan: 'enrollments', input: { sort: '-createdAt', limit: maxLimit, ...(where && { where }) } })).docs
|
|
65
|
+
const sessions = await byIds({ client, maxLimit, kayan: 'sessions', ids: idsOf(seats, 'session') })
|
|
66
|
+
const courses = await byIds({ client, maxLimit, kayan: 'courses', ids: idsOf([...sessions.values()], 'course') })
|
|
67
|
+
const [covers, students] = await Promise.all([
|
|
68
|
+
byIds({ client, maxLimit, kayan: 'media', ids: idsOf([...courses.values()], 'cover') }),
|
|
69
|
+
people ? byIds({ client, maxLimit, kayan: 'user', ids: idsOf(seats, 'student') }) : new Map<string, Row>(),
|
|
70
|
+
])
|
|
71
|
+
return seats.map((seat) => {
|
|
72
|
+
const session = sessions.get(String(seat.session))
|
|
73
|
+
const course = session && courses.get(String(session.course))
|
|
74
|
+
return { seat, startsAt: session?.startsAt, session, course, cover: course && covers.get(String(course.cover)), student: students.get(String(seat.student)) }
|
|
75
|
+
})
|
|
76
|
+
}
|