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,601 @@
|
|
|
1
|
+
import { defaultFields, draftFields, folderOf, invalid, type Ctx, type ReadTools, type WriteTools } from 'mithaq'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { defineKayan, organization } from '@asas/mithaq'
|
|
4
|
+
import { activity, activityTab, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
5
|
+
import { readsPast } from '@asas/mithaq/roles/tenant-access.ts'
|
|
6
|
+
import { sessions } from '../sessions/sessions.ts'
|
|
7
|
+
import { seatsTaken } from '../sessions/seats.ts'
|
|
8
|
+
import { expectedUpdatedAt } from '@asas/mithaq/steps/expected-updated-at.ts'
|
|
9
|
+
import { archivedRow, marks, notArchived, reason, reasonInput, unarchived } from '../domain/archive.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Page pieces only this kayan draws with. What both screens share is reached
|
|
13
|
+
* through `ui`, `dashboardUi` and `portalUi` from `../mithaq.ts`.
|
|
14
|
+
*/
|
|
15
|
+
const ui = folderOf(new URL('./ui/', import.meta.url))
|
|
16
|
+
/** The portal's pages, which the courses feature draws for its students. */
|
|
17
|
+
const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
|
|
18
|
+
/** What a fee column draws on every screen. */
|
|
19
|
+
const feeCell = ui('fee-cell.tsx#FeeCell')
|
|
20
|
+
/** What the list's Status column draws. */
|
|
21
|
+
const statusCell = ui('status-cell.tsx#StatusCell')
|
|
22
|
+
|
|
23
|
+
/** When the access code is drawn: while the form holds a live delivery, redrawn as the delivery changes. */
|
|
24
|
+
const live = { delivery: { equals: 'live' } }
|
|
25
|
+
/** The edit page's header pill for an archived course, beside its title and draft status. */
|
|
26
|
+
const archivedBadge = { component: ui('archived-badge.tsx#ArchivedBadge'), when: archivedRow }
|
|
27
|
+
/** Active, Drafts, Archived and All: Active is what a list opens on, so an archived course is out of the way. */
|
|
28
|
+
const stateTabs = [
|
|
29
|
+
{ tab: 'active', label: { en: 'Active', ar: 'النشطة' }, where: notArchived },
|
|
30
|
+
{ tab: 'drafts', label: { en: 'Drafts', ar: 'المسودات' }, where: { and: [notArchived, { _status: { equals: 'draft' } }] } },
|
|
31
|
+
{ tab: 'archived', label: { en: 'Archived', ar: 'المؤرشفة' }, where: { archived: { equals: true } } },
|
|
32
|
+
{ tab: 'all', label: { en: 'All', ar: 'الكل' } },
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
/** Whether the actor runs the catalogue and so reads every course and every session. */
|
|
36
|
+
export const runsCatalogue = (user: unknown): boolean => readsPast(user, 'courses.archive') || readsPast(user, 'screen.dashboard')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Archived courses are closed to students and instructors and open to whoever
|
|
40
|
+
* runs the catalogue. The dashboard's screen key counts because the registrar
|
|
41
|
+
* reads archived courses there without holding `courses.archive`. Every read
|
|
42
|
+
* answers from this, including relationships; the portal's `baseFilter` only
|
|
43
|
+
* narrows its list. Both arms, since a row from before the column existed holds `null`.
|
|
44
|
+
*/
|
|
45
|
+
const archivedToArchivers = ({ user }: { user: unknown }) =>
|
|
46
|
+
runsCatalogue(user) ? true : { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a course's create and edit pages both draw, written once so the two
|
|
50
|
+
* cannot drift. The access code follows the delivery the form holds.
|
|
51
|
+
*/
|
|
52
|
+
const details = [
|
|
53
|
+
{ section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
|
|
54
|
+
{ section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }] },
|
|
55
|
+
{ section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'notifications', { field: 'selfEnroll', component: 'switch' }] },
|
|
56
|
+
] as const
|
|
57
|
+
const catalogue = { section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags'] } as const
|
|
58
|
+
/**
|
|
59
|
+
* The edit page's Catalogue, with `archived` read only: a course is archived
|
|
60
|
+
* through `archive` alone, which records its reason. A create leaves it out.
|
|
61
|
+
*/
|
|
62
|
+
const archivedCatalogue = { ...catalogue, fields: [...catalogue.fields, { field: 'archived', readOnly: true }] } as const
|
|
63
|
+
|
|
64
|
+
/** What a course's create operation accepts for each session it makes with it. */
|
|
65
|
+
const sessionInput = z.object({ startsAt: z.date(), capacity: z.number().nullish() })
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The step after `insert`. Each session is one managed write through
|
|
69
|
+
* `tools.of(sessions)`, which carries the actor, the depth counter and the
|
|
70
|
+
* transaction, so a session that throws rolls the course back.
|
|
71
|
+
*
|
|
72
|
+
* `sessions` imports this module for its `course` thunk, so the parameters and
|
|
73
|
+
* the return are annotated: inferring them from that call would make the two
|
|
74
|
+
* kayans' types circular.
|
|
75
|
+
*/
|
|
76
|
+
const createSessions = async (
|
|
77
|
+
ctx: Ctx<{ id?: string; _status?: string | null; sessions?: readonly z.infer<typeof sessionInput>[] }>,
|
|
78
|
+
tools: WriteTools,
|
|
79
|
+
): Promise<Ctx> => {
|
|
80
|
+
const { id, _status, sessions: made } = ctx.data
|
|
81
|
+
if (!made?.length) return ctx
|
|
82
|
+
// A draft is read by no plain read, so a session pointing at it would name a
|
|
83
|
+
// course that read cannot show. The runner has written `_status` by now; the
|
|
84
|
+
// throw rolls the course back.
|
|
85
|
+
if (_status === 'draft') {
|
|
86
|
+
throw invalid({
|
|
87
|
+
message: 'A course saved as a draft makes no sessions. Publish the course, then add its sessions.',
|
|
88
|
+
key: 'error.invalid.draft_sessions',
|
|
89
|
+
params: { kayan: 'courses' },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
// The insert step has run, so `id` is set; the create shape types it optional
|
|
93
|
+
// because a caller may leave it to the column's default.
|
|
94
|
+
if (!id) throw new Error('The course was inserted with no id, so its sessions have nothing to point at.')
|
|
95
|
+
for (const session of made) {
|
|
96
|
+
await tools.of(sessions).create({ ...session, course: id })
|
|
97
|
+
}
|
|
98
|
+
return ctx
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What `findWithSessions` puts on the course, one entry per session it may
|
|
103
|
+
* read, with the seats it has left: `null` where it holds any number. A reader
|
|
104
|
+
* who sees only its own seats, a student, could not count the others'.
|
|
105
|
+
*/
|
|
106
|
+
const sessionRow = z.object({
|
|
107
|
+
id: z.guid(),
|
|
108
|
+
startsAt: z.date(),
|
|
109
|
+
capacity: z.number().nullable(),
|
|
110
|
+
venue: z.string().nullish(),
|
|
111
|
+
format: z.string().nullish(),
|
|
112
|
+
archived: z.boolean().nullish(),
|
|
113
|
+
seatsLeft: z.number().nullable(),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The step after `findOne`. The course is loaded by then, so its sessions are
|
|
118
|
+
* one managed read through `tools.of(sessions)`, which runs the sessions kayan's own
|
|
119
|
+
* access, so a session this actor may not read is not in what comes back. A
|
|
120
|
+
* limit of zero is no page limit.
|
|
121
|
+
*/
|
|
122
|
+
const attachSessions = async (ctx: Ctx, tools: ReadTools): Promise<Ctx> => {
|
|
123
|
+
// `ctx.data` is typed as what the operation parses, but this step runs after
|
|
124
|
+
// `findOne`, which leaves the row or `null`: narrow it rather than cast.
|
|
125
|
+
const course: unknown = ctx.data
|
|
126
|
+
if (!course || typeof course !== 'object' || !('id' in course) || typeof course.id !== 'string') return ctx
|
|
127
|
+
const found = await tools.of(sessions).find({
|
|
128
|
+
where: { course: { equals: course.id } },
|
|
129
|
+
limit: 0,
|
|
130
|
+
})
|
|
131
|
+
// Each session was read under the reader's access, so only the seats of a session it may read are counted.
|
|
132
|
+
// ponytail: one count per session, a grouped count when a course holds many sessions.
|
|
133
|
+
const counted = await Promise.all(found.docs.map(async (one) => ({
|
|
134
|
+
...one,
|
|
135
|
+
seatsLeft: typeof one.capacity === 'number' ? Math.max(0, one.capacity - (await seatsTaken({ session: one.id }))) : null,
|
|
136
|
+
})))
|
|
137
|
+
return { ...ctx, data: { ...course, sessions: counted } }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const courses = defineKayan({
|
|
141
|
+
name: 'courses',
|
|
142
|
+
// ANDed with the inherited rows, so it narrows and never widens.
|
|
143
|
+
access: { read: archivedToArchivers },
|
|
144
|
+
/**
|
|
145
|
+
* The singular and plural names are reserved keys of the kayan's own copy
|
|
146
|
+
* scope. The nav and every screen's heading read them, and a kayan that
|
|
147
|
+
* declares neither is shown under its own name.
|
|
148
|
+
*/
|
|
149
|
+
messages: {
|
|
150
|
+
singular: { en: 'Course', ar: 'دورة' },
|
|
151
|
+
plural: { en: 'Courses', ar: 'الدورات' },
|
|
152
|
+
// Nested under the field's own name so that name is written once; `buildAsas`
|
|
153
|
+
// joins the parts into `fee.invalid` and `fee.min`. `fee` carries its own zod
|
|
154
|
+
// shape, so mithaq seeds neither key: declaring them here keeps
|
|
155
|
+
// `mithaq generate messages` from pruning them.
|
|
156
|
+
fee: {
|
|
157
|
+
invalid: { en: 'A fee is a number.', ar: 'الرسوم رقم.' },
|
|
158
|
+
min: { en: 'A fee is never negative.', ar: 'الرسوم لا تكون سالبة أبدًا.' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
targets: {
|
|
162
|
+
/** Every organization's rows on the backoffice, read-only, with each row's record page and its sessions. */
|
|
163
|
+
backoffice: {
|
|
164
|
+
useAsTitle: 'title',
|
|
165
|
+
pages: {
|
|
166
|
+
...backofficeList('/courses', { columns: ['title', 'organization', 'fee', 'archived', 'createdAt'], filters: ['organization', 'title', 'fee', 'archived'], search: ['title'], sort: '-createdAt' }),
|
|
167
|
+
...backofficeRecord({
|
|
168
|
+
path: '/courses/:id',
|
|
169
|
+
main: [{ tabs: [
|
|
170
|
+
{ tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
|
|
171
|
+
{ section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'details'] },
|
|
172
|
+
{ section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, 'audience', 'topics'] },
|
|
173
|
+
{ section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', 'accessCode', 'level', 'opensOn', 'selfEnroll'] },
|
|
174
|
+
{ section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['archiveReason'] },
|
|
175
|
+
] },
|
|
176
|
+
{ tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
|
|
177
|
+
of: 'sessions',
|
|
178
|
+
where: 'course',
|
|
179
|
+
columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
|
|
180
|
+
filters: ['venue', 'archived'],
|
|
181
|
+
search: ['venue'],
|
|
182
|
+
sort: 'startsAt',
|
|
183
|
+
} }] },
|
|
184
|
+
] }],
|
|
185
|
+
side: [{ section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags', 'archived', 'notifications'] }],
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* A course keeps a copy of itself on every write. A change may be saved as a
|
|
191
|
+
* draft, autosaved in place, before it is published; a plain read leaves a
|
|
192
|
+
* course never published out.
|
|
193
|
+
*/
|
|
194
|
+
versions: { drafts: { autosave: true } },
|
|
195
|
+
/**
|
|
196
|
+
* The operations this kayan adds or restates. The store and schema they run
|
|
197
|
+
* through are the service target's own.
|
|
198
|
+
*/
|
|
199
|
+
service: {
|
|
200
|
+
// The defaults hold versions' three operations too, since a course keeps its versions.
|
|
201
|
+
operations: ({ defaults }) => ({
|
|
202
|
+
...defaults,
|
|
203
|
+
/**
|
|
204
|
+
* The shipped create list with one step added and one shape widened:
|
|
205
|
+
* `sessions` is part of the create signature without being a column, since
|
|
206
|
+
* the store step writes the kayan's own fields alone.
|
|
207
|
+
*/
|
|
208
|
+
create: {
|
|
209
|
+
input: ({ create }) => create.extend({ sessions: z.array(sessionInput).optional() }),
|
|
210
|
+
steps: ({ steps }) => [steps.insert, createSessions],
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* The shipped update and delete lists with the `expectedUpdatedAt` step
|
|
214
|
+
* before each, and each input widened by the key the step reads. A draft
|
|
215
|
+
* reader holds the instant of the row's latest version, so a draft save,
|
|
216
|
+
* an autosave, a publish or a Discard is refused 409 when another writer
|
|
217
|
+
* saved since. The edit form sends the key on each; it stays optional, so
|
|
218
|
+
* a caller that sends none, a bulk among them, writes as before.
|
|
219
|
+
*/
|
|
220
|
+
update: {
|
|
221
|
+
input: ({ update }) => update.extend({ expectedUpdatedAt: z.date().optional() }),
|
|
222
|
+
steps: ({ steps }) => [expectedUpdatedAt, steps.update],
|
|
223
|
+
},
|
|
224
|
+
delete: {
|
|
225
|
+
input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
|
|
226
|
+
steps: ({ steps }) => [expectedUpdatedAt, steps.remove],
|
|
227
|
+
},
|
|
228
|
+
/**
|
|
229
|
+
* A restore takes the key too, so Discard changes and a restore from the
|
|
230
|
+
* Versions tab are refused 409 over a newer draft another person saved,
|
|
231
|
+
* which a restore would otherwise bury.
|
|
232
|
+
*/
|
|
233
|
+
restoreVersion: {
|
|
234
|
+
input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
|
|
235
|
+
steps: ({ steps }) => [expectedUpdatedAt, steps.restoreVersion],
|
|
236
|
+
},
|
|
237
|
+
/**
|
|
238
|
+
* A custom operation is one more list and gets what `create` gets: the
|
|
239
|
+
* transaction, the parse and the defaults. Its name is the action an access
|
|
240
|
+
* row grants. Naming `input` orders the two the runner owns: the caller's
|
|
241
|
+
* shape is parsed first and the defaults fill after it, so `updatedAt` moves
|
|
242
|
+
* as in the shipped `update` without this strict shape naming a key the
|
|
243
|
+
* caller never sends.
|
|
244
|
+
*/
|
|
245
|
+
archive: {
|
|
246
|
+
// What a screen's button asks for beyond the row's id, said as fields: the
|
|
247
|
+
// dialog draws `reason` through the control every text field uses and keys
|
|
248
|
+
// its label `courses.operations.archive.reason`. `buildAsas` refuses a
|
|
249
|
+
// field it names no key for. A screen that declares none has no dialog.
|
|
250
|
+
targets: { dashboard: { fields: { reason } } },
|
|
251
|
+
input: () => reasonInput('courses.operations.archive'),
|
|
252
|
+
steps: ({ steps }) => [marks(true), steps.update],
|
|
253
|
+
},
|
|
254
|
+
/** Brings an archived course back into the catalogue, with the reason, which the activity keeps. */
|
|
255
|
+
unarchive: {
|
|
256
|
+
targets: { dashboard: { fields: { reason } } },
|
|
257
|
+
input: () => reasonInput('courses.operations.unarchive'),
|
|
258
|
+
steps: ({ steps }) => [marks(false), steps.update],
|
|
259
|
+
},
|
|
260
|
+
/**
|
|
261
|
+
* Payload's Unpublish: the course goes back to a draft, kept as it is and
|
|
262
|
+
* read by draft readers alone until it is published again. The caller
|
|
263
|
+
* sends the status it asks for, since an operation of its own keeps the
|
|
264
|
+
* row's status unless the call names one.
|
|
265
|
+
*/
|
|
266
|
+
unpublish: {
|
|
267
|
+
input: ({ id }) => id.extend({ _status: z.literal('draft') }),
|
|
268
|
+
steps: ({ steps }) => [steps.update],
|
|
269
|
+
},
|
|
270
|
+
/**
|
|
271
|
+
* A read list with one step of its own and an `output` written the way
|
|
272
|
+
* `input` is, run once at `buildAsas`. It types `sessions` on the result,
|
|
273
|
+
* which no field declares and the row boundary does not know.
|
|
274
|
+
*/
|
|
275
|
+
findWithSessions: {
|
|
276
|
+
// The id and nothing else, picked off the derived row shape. The row is
|
|
277
|
+
// stripped rather than strict, because it comes from the store; `.strict()`
|
|
278
|
+
// puts that refusal back.
|
|
279
|
+
input: ({ row }) => row.pick({ id: true }).strict(),
|
|
280
|
+
steps: ({ steps }) => [steps.findOne, attachSessions],
|
|
281
|
+
// Every column optional but the id: the output is read after the reader's projection, which leaves out a
|
|
282
|
+
// column the reader holds no key for, as a student's leaves out the access code.
|
|
283
|
+
output: ({ row }) => row.partial().required({ id: true }).extend({ sessions: z.array(sessionRow) }),
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
},
|
|
287
|
+
dashboard: {
|
|
288
|
+
// What this kayan is for, drawn under the list heading and the form title.
|
|
289
|
+
// One string per declared locale, keyed `courses.page.description`.
|
|
290
|
+
description: {
|
|
291
|
+
en: 'Everything on offer in the catalogue, and what a seat on each costs.',
|
|
292
|
+
ar: 'كل ما هو معروض في الدليل، وما يكلفه مقعد في كل منها.',
|
|
293
|
+
},
|
|
294
|
+
useAsTitle: 'title',
|
|
295
|
+
// Each page is one tree: the `app` shell at its root with the title in its
|
|
296
|
+
// header and the nav in its nav region, a layout in its main region, and what
|
|
297
|
+
// fills each region. Between them they draw a row, sections, tabs, a related
|
|
298
|
+
// list, a component, a danger-zone button that asks before it runs, and a
|
|
299
|
+
// read-only leaf. The board is rooted at the second shell, `top`, and names
|
|
300
|
+
// its layout and component by `ui()`.
|
|
301
|
+
pages: {
|
|
302
|
+
list: {
|
|
303
|
+
path: '/courses',
|
|
304
|
+
page: appPage([{ layout: 'table', main: [{ list: {
|
|
305
|
+
// `_status` says whether each row is a draft or published, since this
|
|
306
|
+
// list reads every row's latest draft.
|
|
307
|
+
columns: ['title', 'fee', 'opensOn', '_status', 'details', 'cover'],
|
|
308
|
+
filters: ['title', 'fee', 'archived'],
|
|
309
|
+
// `details` is searched because this list names it: a rich text field
|
|
310
|
+
// offers itself to no search box on its own.
|
|
311
|
+
search: ['title', 'summary', 'details'],
|
|
312
|
+
empty: ui('no-courses.tsx#NoCourses'),
|
|
313
|
+
// Over the ticked rows: a delete behind its question, and one dialog that
|
|
314
|
+
// writes the fields it names over every one of them.
|
|
315
|
+
bulk: [
|
|
316
|
+
{
|
|
317
|
+
button: 'deleteMany',
|
|
318
|
+
label: { en: 'Delete', ar: 'حذف' },
|
|
319
|
+
confirm: {
|
|
320
|
+
title: { en: 'Delete the ticked courses?', ar: 'حذف الدورات المحددة؟' },
|
|
321
|
+
body: { en: 'This cannot be undone.', ar: 'لا يمكن التراجع عن هذا.' },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
// Archived is not among them: a course leaves and comes back through its own operations.
|
|
325
|
+
{ button: 'updateMany', label: { en: 'Update', ar: 'تحديث' }, fields: ['fee', 'notifications'] },
|
|
326
|
+
],
|
|
327
|
+
// On each row, the edit page's own archive or unarchive, asking for its reason.
|
|
328
|
+
row: [
|
|
329
|
+
{ button: 'archive', when: unarchived, label: { en: 'Archive', ar: 'أرشفة' }, fields: ['reason'] },
|
|
330
|
+
{ button: 'unarchive', when: archivedRow, label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' }, fields: ['reason'] },
|
|
331
|
+
],
|
|
332
|
+
tabs: stateTabs,
|
|
333
|
+
} }] }]),
|
|
334
|
+
},
|
|
335
|
+
// The edit page's Details and Catalogue, and nothing that needs a row yet:
|
|
336
|
+
// no Sessions or Versions tab, no activity, no danger zone.
|
|
337
|
+
create: {
|
|
338
|
+
path: '/courses/new',
|
|
339
|
+
page: appPage([{ layout: 'record', main: [{ form: details }], side: [catalogue] }]),
|
|
340
|
+
},
|
|
341
|
+
// The editor's tabs sit across the page, outside the cards. On Details a
|
|
342
|
+
// main column of sectioned fields has a side column beside it, and the danger
|
|
343
|
+
// zone is at its foot. Sessions and Versions take the page's full width, and
|
|
344
|
+
// the form's controls sit in the header on every tab. The page names every
|
|
345
|
+
// field it draws, and no id, tenant or timestamp.
|
|
346
|
+
edit: {
|
|
347
|
+
path: '/courses/:id',
|
|
348
|
+
page: { ...appPage([{ tabs: [
|
|
349
|
+
{ tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{
|
|
350
|
+
layout: 'record',
|
|
351
|
+
main: [{ form: details }],
|
|
352
|
+
// Beside the form, what was done to this row, by whom and when.
|
|
353
|
+
side: [archivedCatalogue, activity],
|
|
354
|
+
// Where a button sits is where it is drawn: in the record layout's danger
|
|
355
|
+
// zone, Unpublish over a published course, Archive over one not archived and
|
|
356
|
+
// Unarchive over one that is, each asking before it runs, before the delete
|
|
357
|
+
// every edit page ends its danger zone with.
|
|
358
|
+
danger: danger({
|
|
359
|
+
button: 'unpublish',
|
|
360
|
+
when: { 'record._status': { equals: 'published' } },
|
|
361
|
+
label: { en: 'Unpublish', ar: 'إلغاء النشر' },
|
|
362
|
+
component: ui('unpublish-button.tsx#UnpublishButton'),
|
|
363
|
+
reload: true,
|
|
364
|
+
confirm: {
|
|
365
|
+
title: { en: 'Unpublish this course?', ar: 'إلغاء نشر هذه الدورة؟' },
|
|
366
|
+
body: { en: 'It goes back to a draft that no student sees, until it is published again.', ar: 'تعود مسودة لا يراها أي طالب حتى تُنشر مجددا.' },
|
|
367
|
+
},
|
|
368
|
+
}, {
|
|
369
|
+
button: 'archive',
|
|
370
|
+
when: unarchived,
|
|
371
|
+
label: { en: 'Archive', ar: 'أرشفة' },
|
|
372
|
+
fields: ['reason'],
|
|
373
|
+
reload: true,
|
|
374
|
+
confirm: {
|
|
375
|
+
title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' },
|
|
376
|
+
body: { en: 'It leaves the catalogue for every student.', ar: 'تخرج من الدليل لكل الطلاب.' },
|
|
377
|
+
},
|
|
378
|
+
}, {
|
|
379
|
+
button: 'unarchive',
|
|
380
|
+
when: archivedRow,
|
|
381
|
+
label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' },
|
|
382
|
+
fields: ['reason'],
|
|
383
|
+
reload: true,
|
|
384
|
+
confirm: {
|
|
385
|
+
title: { en: 'Unarchive this course?', ar: 'إلغاء أرشفة هذه الدورة؟' },
|
|
386
|
+
body: { en: 'It is back in the catalogue as it was.', ar: 'تعود إلى الدليل كما كانت.' },
|
|
387
|
+
},
|
|
388
|
+
}),
|
|
389
|
+
}] },
|
|
390
|
+
// The sessions are rows of another kayan pointing at this one: a list of
|
|
391
|
+
// `sessions` whose `course` is this row, searched, filtered and paged on
|
|
392
|
+
// the server, each row edited in place below the table or on its own page,
|
|
393
|
+
// and every write the sessions kayan's own, apart from this form's save.
|
|
394
|
+
{ tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
|
|
395
|
+
of: 'sessions',
|
|
396
|
+
where: 'course',
|
|
397
|
+
columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
|
|
398
|
+
filters: ['venue', 'archived'],
|
|
399
|
+
search: ['venue'],
|
|
400
|
+
sort: 'startsAt',
|
|
401
|
+
bulk: [{
|
|
402
|
+
button: 'archiveMany',
|
|
403
|
+
label: { en: 'Archive', ar: 'أرشفة' },
|
|
404
|
+
confirm: {
|
|
405
|
+
title: { en: 'Archive the ticked sessions?', ar: 'أرشفة الجلسات المحددة؟' },
|
|
406
|
+
body: { en: 'They stay on the course, marked archived.', ar: 'تبقى في الدورة، معلمة بأنها مؤرشفة.' },
|
|
407
|
+
},
|
|
408
|
+
}],
|
|
409
|
+
} }] },
|
|
410
|
+
// The row's versions, newest first, each restored behind its confirm,
|
|
411
|
+
// drawn only for an actor holding the key that reads them; a tab whose
|
|
412
|
+
// every node is hidden is not drawn at all.
|
|
413
|
+
{ tab: 'versions', label: { en: 'Versions', ar: 'الإصدارات' }, fields: [
|
|
414
|
+
{ component: 'versions', when: { '$user.collections.courses.operations.findVersions': { equals: true } } },
|
|
415
|
+
] },
|
|
416
|
+
// Beside them, the row's whole activity, searched and filtered; the side column keeps the last few.
|
|
417
|
+
activityTab,
|
|
418
|
+
] }]), header: [{ title: {} }, archivedBadge, { controls: {} }] },
|
|
419
|
+
},
|
|
420
|
+
board: {
|
|
421
|
+
path: '/courses/board',
|
|
422
|
+
page: { shell: 'top', header: [{ title: {} }], nav: [{ nav: {} }], main: [{ layout: 'table', main: [{ component: ui('no-courses.tsx#NoCourses') }] }] },
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
/**
|
|
427
|
+
* The students' own pages, each drawn whole by the courses feature's portal: the catalogue of what is on
|
|
428
|
+
* offer, and a course's page with its sessions and, where the course lets students enroll themselves, the
|
|
429
|
+
* seats they take. Both read the courses that are published and not archived alone.
|
|
430
|
+
*/
|
|
431
|
+
portal: {
|
|
432
|
+
useAsTitle: 'title',
|
|
433
|
+
description: { en: 'The courses on offer.', ar: 'الدورات المعروضة.' },
|
|
434
|
+
pages: {
|
|
435
|
+
list: { path: '/courses', component: portalUi('catalogue.tsx#Catalogue') },
|
|
436
|
+
view: { path: '/courses/:id', component: portalUi('course.tsx#Course') },
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
fields: {
|
|
441
|
+
...defaultFields,
|
|
442
|
+
organization,
|
|
443
|
+
title: {
|
|
444
|
+
kind: 'text',
|
|
445
|
+
label: { en: 'Title', ar: 'العنوان' },
|
|
446
|
+
// What the field is and the hint its empty control shows, written once
|
|
447
|
+
// at the field's root and drawn the same on every screen. Both are copy,
|
|
448
|
+
// keyed `courses.title.description` and `courses.title.placeholder`.
|
|
449
|
+
description: { en: 'The name a student sees in the catalogue.', ar: 'الاسم الذي يراه الطالب في الدليل.' },
|
|
450
|
+
placeholder: { en: 'Introduction to Arabic calligraphy', ar: 'مقدمة في الخط العربي' },
|
|
451
|
+
// What the input group draws before the value, one string per locale,
|
|
452
|
+
// on every screen that draws the field.
|
|
453
|
+
prefix: { en: 'Course', ar: 'دورة' },
|
|
454
|
+
targets: {
|
|
455
|
+
dashboard: {
|
|
456
|
+
filter: true,
|
|
457
|
+
// What the list's search box runs a `like` over. One opt-in per
|
|
458
|
+
// field, so a name no field of this kayan carries cannot be written.
|
|
459
|
+
searchable: true,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
// Trimmed before it is judged, so a title of spaces alone is refused as
|
|
463
|
+
// required on publish rather than saved as a course with no name. A draft
|
|
464
|
+
// save parses the kinds alone and keeps it.
|
|
465
|
+
rules: { required: true, trim: true, maxLength: 120, index: true },
|
|
466
|
+
},
|
|
467
|
+
// Optional, and carrying a length rule: the rule has to run on the string
|
|
468
|
+
// shape, which is only true while the shape is still a string.
|
|
469
|
+
summary: {
|
|
470
|
+
kind: 'text',
|
|
471
|
+
label: { en: 'Summary', ar: 'الملخص' },
|
|
472
|
+
rules: { maxLength: 200 },
|
|
473
|
+
// Searched beside the title, and never ordered by: a summary is a
|
|
474
|
+
// paragraph, so a page sorted by it reads as an arbitrary order, and a
|
|
475
|
+
// caller naming it is refused the way a caller naming a localized field is.
|
|
476
|
+
targets: { dashboard: { searchable: true, sortable: false } },
|
|
477
|
+
},
|
|
478
|
+
// A fee is never negative. `rules: { min: 0 }` would say so with the same
|
|
479
|
+
// two keys; the shape is this field's own on purpose, as the template's one
|
|
480
|
+
// field that carries its whole zod shape. It is `nullish` because the form
|
|
481
|
+
// sends `null` for a fee it cleared. The column stays derived.
|
|
482
|
+
//
|
|
483
|
+
// A shape written here is one mithaq wrote none of, so it names its own copy
|
|
484
|
+
// keys or an Arabic reader gets zod's English: `courses.fee.invalid` for a
|
|
485
|
+
// value that is not a number and `courses.fee.min` for a negative one.
|
|
486
|
+
// Neither is seeded, because the seed loop passes over a field whose schema
|
|
487
|
+
// target carries its own shape; this kayan's `messages` entry above claims both.
|
|
488
|
+
fee: {
|
|
489
|
+
kind: 'number',
|
|
490
|
+
label: { en: 'Fee', ar: 'الرسوم' },
|
|
491
|
+
// And after it: what one fee buys.
|
|
492
|
+
suffix: { en: 'per seat', ar: 'لكل مقعد' },
|
|
493
|
+
// A column reads the fee in the row's own currency on every screen
|
|
494
|
+
// A column reads the fee in the row's own currency on every screen.
|
|
495
|
+
targets: {
|
|
496
|
+
zod: z.number({ error: 'courses.fee.invalid' }).nonnegative({ error: 'courses.fee.min' }).nullish(),
|
|
497
|
+
dashboard: { filter: true, cell: feeCell },
|
|
498
|
+
portal: { cell: feeCell },
|
|
499
|
+
backoffice: { cell: feeCell },
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
currency: {
|
|
503
|
+
kind: 'text',
|
|
504
|
+
label: { en: 'Currency', ar: 'العملة' },
|
|
505
|
+
options: [
|
|
506
|
+
{ value: 'SAR', label: { en: 'Saudi riyal', ar: 'ريال سعودي' } },
|
|
507
|
+
{ value: 'USD', label: { en: 'US dollar', ar: 'دولار أمريكي' } },
|
|
508
|
+
],
|
|
509
|
+
// One field, drawn as a `select` on the dashboard and as `radio` on the
|
|
510
|
+
// portal: what it holds is written once, and each screen says only how
|
|
511
|
+
// it draws it.
|
|
512
|
+
targets: { dashboard: { component: 'select' }, portal: { component: 'radio' } },
|
|
513
|
+
},
|
|
514
|
+
level: {
|
|
515
|
+
kind: 'number', label: { en: 'Level', ar: 'المستوى' }, rules: { min: 1, max: 5 },
|
|
516
|
+
targets: { dashboard: { component: 'slider' } },
|
|
517
|
+
},
|
|
518
|
+
notifications: {
|
|
519
|
+
kind: 'checkbox', label: { en: 'Notifications', ar: 'الإشعارات' },
|
|
520
|
+
targets: { dashboard: { component: 'switch', cell: 'checkbox' } },
|
|
521
|
+
},
|
|
522
|
+
delivery: {
|
|
523
|
+
kind: 'text', label: { en: 'Delivery', ar: 'طريقة التقديم' },
|
|
524
|
+
options: [
|
|
525
|
+
{ value: 'live', label: { en: 'Live', ar: 'مباشر' } },
|
|
526
|
+
{ value: 'recorded', label: { en: 'Recorded', ar: 'مسجل' } },
|
|
527
|
+
],
|
|
528
|
+
// A choice on both screens, so each reads the option's label in the
|
|
529
|
+
// viewer's locale and never the stored value.
|
|
530
|
+
targets: { dashboard: { component: 'radio' }, portal: { component: 'radio' } },
|
|
531
|
+
},
|
|
532
|
+
audience: {
|
|
533
|
+
kind: 'text', label: { en: 'Audience', ar: 'الجمهور' },
|
|
534
|
+
options: [
|
|
535
|
+
{ value: 'beginner', label: { en: 'Beginner', ar: 'مبتدئ' } },
|
|
536
|
+
{ value: 'advanced', label: { en: 'Advanced', ar: 'متقدم' } },
|
|
537
|
+
],
|
|
538
|
+
targets: { dashboard: { component: 'toggleGroup' } },
|
|
539
|
+
},
|
|
540
|
+
accessCode: {
|
|
541
|
+
kind: 'text', label: { en: 'Access code', ar: 'رمز الدخول' }, rules: { maxLength: 6 },
|
|
542
|
+
targets: { dashboard: { component: 'otp' } },
|
|
543
|
+
},
|
|
544
|
+
// Written by `archive` and `unarchive` alone, which record why: no create or
|
|
545
|
+
// plain update names it, so no key past theirs archives a course.
|
|
546
|
+
archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشف' }, rules: { providedBy: { create: 'never', update: 'application' } }, targets: { dashboard: { filter: true } } },
|
|
547
|
+
// What a course covers, several at once out of a closed list. A course names
|
|
548
|
+
// at most two, so a third box ticked is refused before the save. No floor: an
|
|
549
|
+
// optional list left empty passes any `minRows`, and a list that is there
|
|
550
|
+
// holds one already.
|
|
551
|
+
topics: {
|
|
552
|
+
kind: 'textArray',
|
|
553
|
+
label: { en: 'Topics', ar: 'المواضيع' },
|
|
554
|
+
rules: { maxRows: 2 },
|
|
555
|
+
options: [
|
|
556
|
+
{ value: 'script', label: { en: 'Script', ar: 'الخط' } },
|
|
557
|
+
{ value: 'grammar', label: { en: 'Grammar', ar: 'النحو' } },
|
|
558
|
+
{ value: 'recitation', label: { en: 'Recitation', ar: 'التلاوة' } },
|
|
559
|
+
],
|
|
560
|
+
},
|
|
561
|
+
// The day enrolment opens, a calendar day with no time of its own, which
|
|
562
|
+
// the portal draws as the backoffice's own day picker.
|
|
563
|
+
opensOn: { kind: 'date', label: { en: 'Opens on', ar: 'يفتح في' } },
|
|
564
|
+
archiveReason: {
|
|
565
|
+
kind: 'text',
|
|
566
|
+
label: { en: 'Archive reason', ar: 'سبب الأرشفة' },
|
|
567
|
+
rules: { maxLength: 200, providedBy: { create: 'never', update: 'application' } },
|
|
568
|
+
},
|
|
569
|
+
// The many-to-many: no column on this table at all, and a join table
|
|
570
|
+
// `courses_tags` the store writes in the same transaction the course is
|
|
571
|
+
// written in. The whole array replaces the old set on an update, and the join
|
|
572
|
+
// rows go with the course when it is deleted.
|
|
573
|
+
tags: {
|
|
574
|
+
kind: 'relationship',
|
|
575
|
+
relationTo: 'tags',
|
|
576
|
+
hasMany: true,
|
|
577
|
+
label: { en: 'Tags', ar: 'الوسوم' },
|
|
578
|
+
},
|
|
579
|
+
// The same rows a read with a depth populates. A page draws them as the list
|
|
580
|
+
// of `sessions` on the edit page's Sessions tab, and never as a leaf.
|
|
581
|
+
scheduledSessions: {
|
|
582
|
+
kind: 'join', relationTo: 'sessions', on: 'course',
|
|
583
|
+
label: { en: 'Scheduled sessions', ar: 'الجلسات المجدولة' },
|
|
584
|
+
},
|
|
585
|
+
// Whether this course is a draft or published; a kayan that keeps drafts spreads these last.
|
|
586
|
+
...draftFields,
|
|
587
|
+
_status: { ...draftFields._status, targets: { dashboard: { cell: statusCell } } },
|
|
588
|
+
// What the course covers at length, as Lexical's editor state, drawn by the
|
|
589
|
+
// registry's editor and read by its viewer, and as its plain text in a list.
|
|
590
|
+
// Declared last so a pushed table and a migrated one hold the column in the same place.
|
|
591
|
+
details: { kind: 'richText', label: { en: 'Details', ar: 'التفاصيل' } },
|
|
592
|
+
// The course's cover, one of the organization's images. Its row cannot be
|
|
593
|
+
// deleted while a course points at it, since an upload restricts. Declared
|
|
594
|
+
// last, for the same column order.
|
|
595
|
+
cover: { kind: 'upload', relationTo: 'media', label: { en: 'Cover image', ar: 'صورة الغلاف' } },
|
|
596
|
+
// Whether a student takes a seat in this course's sessions from the portal: only where it is true, so a course
|
|
597
|
+
// that never says is one whose staff enroll each student. No default, which every create would then write, and
|
|
598
|
+
// a role without the column's key would be refused. Declared last, for the same column order.
|
|
599
|
+
selfEnroll: { kind: 'checkbox', label: { en: 'Students may enroll themselves', ar: 'يسجّل الطلاب أنفسهم' } },
|
|
600
|
+
},
|
|
601
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { actorPermissions, type Where } from 'mithaq'
|
|
2
|
+
import { serverTargets } from '@asas/mithaq'
|
|
3
|
+
import { currentOrganization } from '@asas/mithaq/roles/tenant-access.ts'
|
|
4
|
+
import { courses } from './courses.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The images an actor reads as covers: the cover of a published course it
|
|
8
|
+
* reads, of the organization its session has active, under the courses kayan's
|
|
9
|
+
* own read access for this actor. So a student or an instructor reads no image
|
|
10
|
+
* of a draft, of an archived course or of none. The media feature asks it of
|
|
11
|
+
* an actor that holds no `media.create`, and holds no import of this file.
|
|
12
|
+
*
|
|
13
|
+
* The organization is read off the trusted snapshot and named here, since the
|
|
14
|
+
* courses are read straight through the store, past the runner's scope. An
|
|
15
|
+
* image has no field that points at a course, so a row filter cannot reach
|
|
16
|
+
* one, and the covers are read first and named by id.
|
|
17
|
+
*/
|
|
18
|
+
export const coverReads = async ({ user }: { user: unknown }): Promise<Where | false> => {
|
|
19
|
+
const held = actorPermissions(user)
|
|
20
|
+
if (held !== true && !held?.has('courses.find')) return false
|
|
21
|
+
const active = currentOrganization(user)
|
|
22
|
+
const readable = active && (await courses.access!.read!({ user }))
|
|
23
|
+
if (!readable) return false
|
|
24
|
+
const published: Where = { and: [{ organization: { equals: active } }, { _status: { equals: 'published' } }] }
|
|
25
|
+
const found = await serverTargets.drizzle.find({ kayan: 'courses', args: {
|
|
26
|
+
limit: 0,
|
|
27
|
+
page: 1,
|
|
28
|
+
sort: { field: 'id', direction: 'asc' },
|
|
29
|
+
where: readable === true ? published : { and: [readable, published] },
|
|
30
|
+
} })
|
|
31
|
+
const ids = [...new Set(found.docs.map((row) => row.cover).filter((id): id is string => typeof id === 'string'))]
|
|
32
|
+
return ids.length ? { id: { in: ids } } : false
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The edit page's header pill for an archived course, beside the title and the
|
|
2
|
+
// draft status, so a course out of the catalogue says so on every tab.
|
|
3
|
+
import { useLocale } from 'mithaq/admin/react'
|
|
4
|
+
import { Badge } from '@/components/ui/badge'
|
|
5
|
+
|
|
6
|
+
export function ArchivedBadge() {
|
|
7
|
+
const { t } = useLocale()
|
|
8
|
+
return <Badge data-status="archived" className="bg-destructive/10 text-destructive">{t('version.archived', 'Archived')}</Badge>
|
|
9
|
+
}
|