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,87 @@
|
|
|
1
|
+
// What the portal's pages decide, apart from what they draw, so a test reads it under plain `node`.
|
|
2
|
+
import { holds, type AccessReport, type Row, type Translate } from 'mithaq/admin'
|
|
3
|
+
|
|
4
|
+
/** Which part of the day a greeting names, by the hour on the viewer's own clock. */
|
|
5
|
+
export const partOfDay = (hour: number) => (hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening')
|
|
6
|
+
|
|
7
|
+
const timeOf = (value: unknown) => (value instanceof Date || typeof value === 'string' ? new Date(value).getTime() : Number.NaN)
|
|
8
|
+
|
|
9
|
+
/** The first session of each course still to start, by the course's id. An archived session takes no seat, so it is never next. */
|
|
10
|
+
export const nextByCourse = (sessions: readonly Row[], now: number): Map<string, Row> => {
|
|
11
|
+
const next = new Map<string, Row>()
|
|
12
|
+
for (const one of sessions) {
|
|
13
|
+
const at = timeOf(one.startsAt)
|
|
14
|
+
if (one.archived === true || !(at > now) || typeof one.course !== 'string') continue
|
|
15
|
+
const held = next.get(one.course)
|
|
16
|
+
if (!held || at < timeOf(held.startsAt)) next.set(one.course, one)
|
|
17
|
+
}
|
|
18
|
+
return next
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Seats split by when their session meets: those to come, soonest first, and those past, latest first. A seat
|
|
23
|
+
* whose session the viewer cannot read has no start and is in neither.
|
|
24
|
+
*/
|
|
25
|
+
export const byWhen = <Seat extends { startsAt?: unknown }>(seats: readonly Seat[], now: number) => {
|
|
26
|
+
const timed = seats.filter((one) => !Number.isNaN(timeOf(one.startsAt)))
|
|
27
|
+
const upcoming = timed.filter((one) => timeOf(one.startsAt) > now).sort((a, b) => timeOf(a.startsAt) - timeOf(b.startsAt))
|
|
28
|
+
const past = timed.filter((one) => timeOf(one.startsAt) <= now).sort((a, b) => timeOf(b.startsAt) - timeOf(a.startsAt))
|
|
29
|
+
return { upcoming, past }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fee in its own currency, a fee of nought as free, and nothing for a course that names none. */
|
|
33
|
+
export const feeOf = ({ fee, currency, locale, t }: { fee: unknown; currency: unknown; locale: string; t: Translate }): string | undefined => {
|
|
34
|
+
if (typeof fee !== 'number') return undefined
|
|
35
|
+
if (fee === 0) return t('portal.free', 'Free')
|
|
36
|
+
try {
|
|
37
|
+
if (typeof currency === 'string' && currency) return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(fee)
|
|
38
|
+
} catch {
|
|
39
|
+
// A code Intl refuses throws a RangeError; the number alone is still true.
|
|
40
|
+
}
|
|
41
|
+
return fee.toLocaleString(locale)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a session says of its seats: nothing where it holds any number, Full, or how many are left. */
|
|
45
|
+
export const seatsOf = ({ seatsLeft, locale, t }: { seatsLeft: unknown; locale: string; t: Translate }): string | undefined => {
|
|
46
|
+
if (typeof seatsLeft !== 'number') return undefined
|
|
47
|
+
if (seatsLeft <= 0) return t('portal.full', 'Full')
|
|
48
|
+
// The plural form the locale takes for the count, as the admin's `counted` reads it, which lives in a `.tsx` file.
|
|
49
|
+
const form = t(`portal.seatsLeft_${new Intl.PluralRules(locale).select(seatsLeft)}`) ?? t('portal.seatsLeft_other', '{count} seats left')!
|
|
50
|
+
return form.replace('{count}', () => seatsLeft.toLocaleString(locale))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hour = 60 * 60 * 1000
|
|
54
|
+
|
|
55
|
+
/** How soon a moment comes, in the viewer's words: in hours under a day, in days past one. */
|
|
56
|
+
export const startsIn = ({ value, now, locale }: { value: unknown; now: number; locale: string }): string | undefined => {
|
|
57
|
+
const at = timeOf(value)
|
|
58
|
+
if (Number.isNaN(at)) return undefined
|
|
59
|
+
const words = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
60
|
+
const hours = Math.round((at - now) / hour)
|
|
61
|
+
return Math.abs(hours) < 24 ? words.format(hours, 'hour') : words.format(Math.round(hours / 24), 'day')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A moment as a student reads it on a card: the day of the week, the date and the time, in the viewer's locale. */
|
|
65
|
+
export const whenOf = ({ value, locale }: { value: unknown; locale: string }): string | undefined => {
|
|
66
|
+
const at = timeOf(value)
|
|
67
|
+
if (Number.isNaN(at)) return undefined
|
|
68
|
+
const date = new Date(at)
|
|
69
|
+
const day = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'short', day: 'numeric' }).format(date)
|
|
70
|
+
return `${day} · ${new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(date)}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether maintenance closes the portal to this viewer. It closes it to a student alone: whoever enters the
|
|
75
|
+
* dashboard runs the school, and whoever reads every seat teaches, so both keep working through it.
|
|
76
|
+
*/
|
|
77
|
+
export const closedTo = ({ maintenance, access }: { maintenance: unknown; access: AccessReport }) =>
|
|
78
|
+
maintenance === true && !holds(access, 'screen.dashboard') && !holds(access, 'enrollments.others')
|
|
79
|
+
|
|
80
|
+
/** A localized value, such as a tag's name, in the viewer's locale, else any locale it holds. */
|
|
81
|
+
export const inLocale = (value: unknown, locale: string): string | undefined => {
|
|
82
|
+
if (typeof value === 'string') return value
|
|
83
|
+
if (!value || typeof value !== 'object') return undefined
|
|
84
|
+
const held = value as Record<string, unknown>
|
|
85
|
+
const own = held[locale]
|
|
86
|
+
return typeof own === 'string' && own ? own : Object.values(held).find((one): one is string => typeof one === 'string' && !!one)
|
|
87
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The portal's frame, its own look apart from the dashboard's: the organization's name at its head, which is the
|
|
2
|
+
// site's name in every locale, the portal's three places as pills across the top from md up and as a tab bar at
|
|
3
|
+
// the foot of a phone, and one column between them. Its colors are the shadcn tokens the primitives read, set
|
|
4
|
+
// here to the portal's teal and amber for each theme, so a Button or a Card inside it is the portal's without a
|
|
5
|
+
// class of its own.
|
|
6
|
+
import '@fontsource-variable/bricolage-grotesque'
|
|
7
|
+
import { use, useState, type ReactNode } from 'react'
|
|
8
|
+
import { BookOpen, ClipboardCheck, House, Wrench, type LucideIcon } from 'lucide-react'
|
|
9
|
+
// add-on: settings
|
|
10
|
+
import { useQuery } from '@tanstack/react-query'
|
|
11
|
+
import type { Row } from 'mithaq/admin'
|
|
12
|
+
import { useClient } from 'mithaq/admin/react'
|
|
13
|
+
// end add-on: settings
|
|
14
|
+
import type { NavEntry, ShellProps } from 'mithaq/admin'
|
|
15
|
+
import { ScreenContext, useAccess, useLocale, useNav, useScreen, useUser } from 'mithaq/admin/react'
|
|
16
|
+
import { holds } from 'mithaq/admin'
|
|
17
|
+
import { currentOf } from 'mithaq-admin/nav-links.tsx'
|
|
18
|
+
import { DiscardDialog } from 'mithaq-admin/discard-dialog.tsx'
|
|
19
|
+
import { SidebarProvider } from '@/components/ui/sidebar'
|
|
20
|
+
import { locales, switchLocale } from '@/copy.ts'
|
|
21
|
+
import { frame, UserMenu } from '@asas/mithaq/ui/app-shell.tsx'
|
|
22
|
+
import { navLabel } from '@asas/mithaq/ui/screen-nav.tsx'
|
|
23
|
+
import { nameIn } from '@asas/mithaq/mithaq/auth/organizations.ts'
|
|
24
|
+
// add-on: settings
|
|
25
|
+
import { closedTo } from './rules.ts'
|
|
26
|
+
// end add-on: settings
|
|
27
|
+
|
|
28
|
+
export const regions = ['header', 'main'] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The portal's colors over shadcn's tokens, light and dark. Teal is the brand, amber its accent; each pair is
|
|
32
|
+
* 4.5:1 or more, text on its ground.
|
|
33
|
+
*/
|
|
34
|
+
const tokens = [
|
|
35
|
+
'[--background:#F5F5F0] [--foreground:#1B1F24] [--card:#FFFFFF] [--card-foreground:#1B1F24]',
|
|
36
|
+
'[--primary:#0F6E6E] [--primary-foreground:#FFFFFF] [--ring:#0F6E6E] [--muted-foreground:#555A60]',
|
|
37
|
+
'[--accent:#EAF3F2] [--accent-foreground:#0F6E6E] [--border:#E4E4DC] [--input:#D9D9D0] [--portal-amber:#E8A33D]',
|
|
38
|
+
'dark:[--background:#0E1414] dark:[--foreground:#ECEFEE] dark:[--card:#172020] dark:[--card-foreground:#ECEFEE]',
|
|
39
|
+
'dark:[--primary:#5FB8B3] dark:[--primary-foreground:#0B2524] dark:[--ring:#5FB8B3] dark:[--muted-foreground:#A4B0AE]',
|
|
40
|
+
'dark:[--accent:#16312F] dark:[--accent-foreground:#9CD9D4] dark:[--border:#26302F] dark:[--input:#34403F] dark:[--portal-amber:#E8A33D]',
|
|
41
|
+
].join(' ')
|
|
42
|
+
|
|
43
|
+
/** The display face of the portal's headings. Its Latin is Bricolage Grotesque; Arabic falls to IBM Plex Sans Arabic. */
|
|
44
|
+
export const display = "font-['Bricolage_Grotesque_Variable','IBM_Plex_Sans_Arabic',sans-serif] font-bold tracking-tight"
|
|
45
|
+
|
|
46
|
+
const icons: Record<string, LucideIcon> = { '/': House, '/courses': BookOpen, '/enrollments': ClipboardCheck }
|
|
47
|
+
|
|
48
|
+
// add-on: settings
|
|
49
|
+
/** The organization's settings as the viewer reads them, or `null` where it may not or none are written yet. */
|
|
50
|
+
const useSettings = () => {
|
|
51
|
+
const client = useClient()
|
|
52
|
+
return useQuery({
|
|
53
|
+
queryKey: ['portal-settings'],
|
|
54
|
+
queryFn: () => (client({ kayan: 'settings', operation: 'findOne', input: {} }) as Promise<Row | null>).catch(() => null),
|
|
55
|
+
staleTime: 60_000,
|
|
56
|
+
}).data
|
|
57
|
+
}
|
|
58
|
+
// end add-on: settings
|
|
59
|
+
|
|
60
|
+
/** One place of the portal: its link, its label, and whether the viewer is on it. */
|
|
61
|
+
const usePlaces = () => {
|
|
62
|
+
const { locale, t } = useLocale()
|
|
63
|
+
const screen = useScreen()
|
|
64
|
+
const access = useAccess()
|
|
65
|
+
const state = use(ScreenContext)
|
|
66
|
+
// A seat of anyone's is no longer the viewer's own, so the list is "My enrollments" only to a student.
|
|
67
|
+
const label = (entry: NavEntry) =>
|
|
68
|
+
entry.kayan === 'enrollments' && !holds(access, 'enrollments.others') ? t('portal.myEnrollments', 'My enrollments')! : navLabel({ entry, screen, locale, t })!
|
|
69
|
+
return [
|
|
70
|
+
{ path: '/', href: screen.path, label: t('portal.home', 'Home')!, current: state?.path === screen.path },
|
|
71
|
+
...useNav().map((entry) => ({ path: entry.path, href: `${screen.path}${entry.path}`, label: label(entry), current: currentOf(entry, state?.kayan?.name) === 'page' })),
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The other language, offered as its own name, so a reader who cannot read this one still finds theirs. */
|
|
76
|
+
function LocaleButton() {
|
|
77
|
+
const { locale } = useLocale()
|
|
78
|
+
const other = locales.find((one) => one !== locale) ?? locale
|
|
79
|
+
// Asked first, in the app's discard dialog, where a form holds a change nobody saved.
|
|
80
|
+
const [asked, setAsked] = useState(false)
|
|
81
|
+
const { t } = useLocale()
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
lang={other}
|
|
87
|
+
aria-label={other === 'ar' ? 'العربية' : 'English'}
|
|
88
|
+
onClick={() => (window.onbeforeunload ? setAsked(true) : switchLocale({ to: other }))}
|
|
89
|
+
className="border-input bg-card text-foreground focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-full border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
90
|
+
>
|
|
91
|
+
{other === 'ar' ? 'ع' : 'EN'}
|
|
92
|
+
</button>
|
|
93
|
+
<DiscardDialog open={asked} onOpenChange={setAsked} onDiscard={() => switchLocale({ to: other })} t={t} />
|
|
94
|
+
</>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What a student meets while the portal is closed for maintenance: why, and where to write meanwhile. */
|
|
99
|
+
function Closed({ contact }: { contact?: string }) {
|
|
100
|
+
const { t } = useLocale()
|
|
101
|
+
return (
|
|
102
|
+
<section data-portal-closed="" className="bg-card mx-auto grid max-w-md justify-items-center gap-3 rounded-3xl px-6 py-12 text-center">
|
|
103
|
+
<Wrench aria-hidden className="text-primary size-10" />
|
|
104
|
+
<h1 className={`${display} text-2xl`}>{t('portal.closed', 'Closed for maintenance')}</h1>
|
|
105
|
+
<p className="text-muted-foreground">{t('portal.closedBody', 'The school is working on the portal. Please come back soon.')}</p>
|
|
106
|
+
{contact ? <Contact email={contact} /> : null}
|
|
107
|
+
</section>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Contact({ email }: { email: string }) {
|
|
112
|
+
const { t } = useLocale()
|
|
113
|
+
return (
|
|
114
|
+
<p className="text-muted-foreground text-sm">
|
|
115
|
+
{t('portal.contact', 'Questions? Write to')}{' '}
|
|
116
|
+
<a href={`mailto:${email}`} dir="ltr" className="text-primary font-medium underline-offset-4 hover:underline">{email}</a>
|
|
117
|
+
</p>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function PortalShell({ regions }: ShellProps<readonly ['header', 'main'], ReactNode>) {
|
|
122
|
+
const { locale, t } = useLocale()
|
|
123
|
+
const user = useUser()
|
|
124
|
+
const places = usePlaces()
|
|
125
|
+
// add-on: settings
|
|
126
|
+
const access = useAccess()
|
|
127
|
+
const settings = useSettings()
|
|
128
|
+
// The page answers what the viewer reads either way; this only draws the closed page in its place.
|
|
129
|
+
const closed = closedTo({ maintenance: settings?.maintenance, access })
|
|
130
|
+
const contact = typeof settings?.contactEmail === 'string' && settings.contactEmail ? settings.contactEmail : undefined
|
|
131
|
+
// end add-on: settings
|
|
132
|
+
// without add-on: settings
|
|
133
|
+
// const closed = false
|
|
134
|
+
// const contact: string | undefined = undefined
|
|
135
|
+
// end without add-on: settings
|
|
136
|
+
const active = user.organizations?.find((one) => one.id === user.activeOrganizationId)
|
|
137
|
+
const site = active ? nameIn(active, locale) : t('portal.title', 'Portal')!
|
|
138
|
+
const nav = t('portal.navigation', 'Portal')
|
|
139
|
+
return (
|
|
140
|
+
<SidebarProvider data-shell="top" data-portal="" style={frame} className={`bg-background text-foreground min-h-svh flex-col ${tokens}`}>
|
|
141
|
+
<header className="bg-card border-b">
|
|
142
|
+
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
|
143
|
+
<a href={places[0]!.href} className="flex min-w-0 items-center gap-2.5 font-semibold">
|
|
144
|
+
<span aria-hidden className="bg-primary text-primary-foreground grid size-9 shrink-0 place-items-center rounded-[10px]">{[...site][0]}</span>
|
|
145
|
+
<span className="truncate">{site}</span>
|
|
146
|
+
</a>
|
|
147
|
+
<nav aria-label={nav} className="hidden gap-1 md:flex">
|
|
148
|
+
{places.map((place) => (
|
|
149
|
+
<a
|
|
150
|
+
key={place.path}
|
|
151
|
+
href={place.href}
|
|
152
|
+
aria-current={place.current ? 'page' : undefined}
|
|
153
|
+
className="text-foreground/80 hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground inline-flex h-11 items-center rounded-full px-4 text-[15px] aria-[current=page]:font-semibold"
|
|
154
|
+
>
|
|
155
|
+
{place.label}
|
|
156
|
+
</a>
|
|
157
|
+
))}
|
|
158
|
+
</nav>
|
|
159
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
160
|
+
<LocaleButton />
|
|
161
|
+
{/* The avatar alone: the menu signs out and switches locale too. */}
|
|
162
|
+
<div className="w-12 [&_[data-slot=avatar]]:grayscale-0 [&_[data-slot=avatar-fallback]]:bg-(--portal-amber) [&_[data-slot=avatar-fallback]]:text-[#1B1F24]">
|
|
163
|
+
<UserMenu />
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</header>
|
|
168
|
+
<main data-region="main" className="mx-auto grid w-full max-w-6xl grid-cols-[minmax(0,1fr)] content-start gap-6 px-4 pt-6 pb-28 sm:px-6 md:pb-12">
|
|
169
|
+
{closed ? <Closed contact={contact} /> : <><div data-region="header">{regions.header}</div>{regions.main}</>}
|
|
170
|
+
</main>
|
|
171
|
+
{contact && !closed ? (
|
|
172
|
+
<footer className="mx-auto w-full max-w-6xl px-4 pb-28 sm:px-6 md:pb-8">
|
|
173
|
+
<Contact email={contact} />
|
|
174
|
+
</footer>
|
|
175
|
+
) : null}
|
|
176
|
+
<nav aria-label={nav} className="bg-card fixed inset-x-0 bottom-0 z-10 grid grid-cols-3 border-t px-2 pt-1.5 pb-[max(env(safe-area-inset-bottom),0.75rem)] md:hidden">
|
|
177
|
+
{places.map((place) => {
|
|
178
|
+
const Icon = icons[place.path] ?? BookOpen
|
|
179
|
+
return (
|
|
180
|
+
<a
|
|
181
|
+
key={place.path}
|
|
182
|
+
href={place.href}
|
|
183
|
+
aria-current={place.current ? 'page' : undefined}
|
|
184
|
+
className="text-muted-foreground aria-[current=page]:text-primary flex min-h-13 flex-col items-center justify-center gap-1 text-xs aria-[current=page]:font-semibold"
|
|
185
|
+
>
|
|
186
|
+
<Icon aria-hidden className="size-[22px]" />
|
|
187
|
+
{place.label}
|
|
188
|
+
</a>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</nav>
|
|
192
|
+
</SidebarProvider>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { sql } from 'mithaq/drizzle'
|
|
2
|
+
import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
|
|
3
|
+
import { serverTargets } from '@asas/mithaq'
|
|
4
|
+
|
|
5
|
+
/** A write's transaction, which is drizzle's here. */
|
|
6
|
+
const txOf = (tx: unknown) => tx as NodePgDatabase
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How many seats of a session are held, a withdrawn one not among them. Counted through the store, past the
|
|
10
|
+
* reader's own access, which a student's narrows to its own seat: a caller reads the session under that access first.
|
|
11
|
+
*/
|
|
12
|
+
export const seatsTaken = async ({ session, tx }: { session: string; tx?: unknown }) =>
|
|
13
|
+
(await serverTargets.drizzle.find({
|
|
14
|
+
tx,
|
|
15
|
+
kayan: 'enrollments',
|
|
16
|
+
args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, where: { and: [{ session: { equals: session } }, { deletedAt: { exists: false } }] } },
|
|
17
|
+
})).totalDocs
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Serializes every write that takes a seat in one session until its transaction ends: a second write waits here,
|
|
21
|
+
* and under Read Committed its count, a statement after the lock, sees what the first one committed, so two
|
|
22
|
+
* writes never both take the last seat.
|
|
23
|
+
*/
|
|
24
|
+
export const holdSeats = async ({ tx, session }: { tx: unknown; session: string }) => {
|
|
25
|
+
await txOf(tx).execute(sql`select pg_advisory_xact_lock(hashtext('mithaq.seats'), hashtext(${session}))`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a seat is judged by, read off the columns in the write's transaction rather than through the actor's
|
|
30
|
+
* projection: a role shown no capacity, start or archived mark would otherwise read none and skip each rule. A
|
|
31
|
+
* caller reads the session under the actor's access first, so this reads only a session the actor may reach. Its
|
|
32
|
+
* `id` is the stored one, the same in every case the caller wrote it. Both rows stay locked before the
|
|
33
|
+
* enrollment's reference check, so an archive or capacity change cannot pass the decision, and the later
|
|
34
|
+
* `FOR KEY SHARE` does not require a lock upgrade. `FOR SHARE` lets enrollments share those rows while
|
|
35
|
+
* holding off their changes; `holdSeats` serializes the seat count for a session with a capacity.
|
|
36
|
+
*
|
|
37
|
+
* The tables and columns are written out, as the drizzle target names them; a rename fails R7a, R7b and R7f.
|
|
38
|
+
*/
|
|
39
|
+
export const sessionHeld = async ({ tx, session }: { tx: unknown; session: string }) =>
|
|
40
|
+
(await txOf(tx).execute<{ id: string; capacity: number | null; startsAt: Date; archived: boolean | null; courseArchived: boolean | null; selfEnroll: boolean | null }>(sql`
|
|
41
|
+
select s.id, s.capacity::float8 as capacity, s.starts_at as "startsAt", s.archived, c.archived as "courseArchived", c.self_enroll as "selfEnroll"
|
|
42
|
+
from course_sessions s join courses c on c.id = s.course_id where s.id = ${session} for share of s, c`)).rows[0]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { defaultFields, defaultOperations, type Ctx, type Where } from 'mithaq'
|
|
2
|
+
import { defineKayan, organization } from '@asas/mithaq'
|
|
3
|
+
import { activity, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
4
|
+
import { courses, runsCatalogue } from '../courses/courses.ts'
|
|
5
|
+
import { archivedRow, marks, reason, reasonInput, unarchived } from '../domain/archive.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hands each matched session's `archive` no reason and nothing else, whatever the call sent as
|
|
9
|
+
* `data`, so the course page's bulk Archive can only archive, never un-archive.
|
|
10
|
+
*/
|
|
11
|
+
const archiveAll = (ctx: Ctx): Ctx => ({ ...ctx, data: { ...ctx.data, data: { reason: '' } } })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a session's create and edit pages both draw, written once so the two cannot drift. The edit
|
|
15
|
+
* page keeps the course on the side; the create page puts it first in the form, because it is
|
|
16
|
+
* required and a side column sits after Save.
|
|
17
|
+
*/
|
|
18
|
+
const details = [
|
|
19
|
+
{ section: 'schedule', label: { en: 'Schedule', ar: 'الموعد' }, fields: [{ row: ['startsAt', 'capacity'] }] },
|
|
20
|
+
{ section: 'place', label: { en: 'Venue and format', ar: 'المكان والصيغة' }, fields: [{ row: ['venue', 'format'] }] },
|
|
21
|
+
{ section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['details'] },
|
|
22
|
+
] as const
|
|
23
|
+
const course = { section: 'course', label: { en: 'Course', ar: 'الدورة' }, fields: ['course'] } as const
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A session an actor reads, inside its active organization: one whose course that
|
|
27
|
+
* actor reads. Whoever runs the catalogue reads every session; a student or an instructor reads a
|
|
28
|
+
* session only when its course is published and not archived, and never the date of one it may not
|
|
29
|
+
* open.
|
|
30
|
+
*/
|
|
31
|
+
const ofReadableCourses = ({ user }: { user: unknown }): true | Where =>
|
|
32
|
+
runsCatalogue(user)
|
|
33
|
+
? true
|
|
34
|
+
: { and: [{ 'course._status': { equals: 'published' } }, { or: [{ 'course.archived': { exists: false } }, { 'course.archived': { equals: false } }] }] }
|
|
35
|
+
|
|
36
|
+
export const sessions = defineKayan({
|
|
37
|
+
name: 'sessions',
|
|
38
|
+
// ANDed with the inherited rows, so it narrows and never widens.
|
|
39
|
+
access: { read: ofReadableCourses },
|
|
40
|
+
// Better Auth owns a table named `session`, and `session` beside `sessions` in
|
|
41
|
+
// one schema is a trap.
|
|
42
|
+
dbName: 'course_sessions',
|
|
43
|
+
targets: {
|
|
44
|
+
backoffice: {
|
|
45
|
+
useAsTitle: 'startsAt',
|
|
46
|
+
pages: {
|
|
47
|
+
...backofficeList('/sessions', { columns: ['startsAt', 'organization', 'course', 'capacity', 'venue'], filters: ['organization', 'course', 'venue', 'archived'], search: ['venue', 'course.title'], sort: '-startsAt' }),
|
|
48
|
+
...backofficeRecord({
|
|
49
|
+
path: '/sessions/:id',
|
|
50
|
+
main: [{ tabs: [
|
|
51
|
+
{ tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
|
|
52
|
+
{ section: 'schedule', label: { en: 'Schedule', ar: 'الموعد' }, fields: [{ row: ['startsAt', 'capacity'] }] },
|
|
53
|
+
{ section: 'place', label: { en: 'Venue and format', ar: 'المكان والصيغة' }, fields: [{ row: ['venue', 'format'] }] },
|
|
54
|
+
{ section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['details'] },
|
|
55
|
+
] },
|
|
56
|
+
{ tab: 'enrollments', label: { en: 'Enrollments', ar: 'التسجيلات' }, fields: [{ list: { of: 'enrollments', where: 'session', columns: ['student', 'createdAt'] } }] },
|
|
57
|
+
] }],
|
|
58
|
+
side: [{ section: 'course', label: { en: 'Course', ar: 'الدورة' }, fields: ['course', 'archived'] }],
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
// The portal lists sessions for this alone: an enrollment's session reads by its start there as it
|
|
63
|
+
// does on the dashboard.
|
|
64
|
+
portal: { useAsTitle: 'startsAt' },
|
|
65
|
+
dashboard: {
|
|
66
|
+
// What a session is shown by wherever it stands for itself: the picker on `enrollments.session`
|
|
67
|
+
// and a relationship column pointing here. A session declares no name, and its start tells two
|
|
68
|
+
// sessions of one course apart, drawn in the viewer's own format. The search boxes come to
|
|
69
|
+
// nothing, because `like` takes text and email.
|
|
70
|
+
useAsTitle: 'startsAt',
|
|
71
|
+
description: {
|
|
72
|
+
en: 'The dates each course meets, and how many seats each one holds.',
|
|
73
|
+
ar: 'مواعيد انعقاد كل دورة، وعدد المقاعد في كل منها.',
|
|
74
|
+
},
|
|
75
|
+
pages: {
|
|
76
|
+
list: { path: '/sessions', page: appPage([{ layout: 'table', main: [{ list: {
|
|
77
|
+
columns: ['course', 'startsAt', 'capacity', 'venue'],
|
|
78
|
+
filters: ['course', 'startsAt', 'venue', 'format', 'archived'],
|
|
79
|
+
search: ['course.title', 'details'],
|
|
80
|
+
} }] }]) },
|
|
81
|
+
create: {
|
|
82
|
+
path: '/sessions/new',
|
|
83
|
+
page: appPage([{ layout: 'record', main: [{ form: [course, ...details] }] }]),
|
|
84
|
+
},
|
|
85
|
+
edit: {
|
|
86
|
+
path: '/sessions/:id',
|
|
87
|
+
page: appPage([{
|
|
88
|
+
layout: 'record',
|
|
89
|
+
main: [{ tabs: [
|
|
90
|
+
{ tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{ form: details }] },
|
|
91
|
+
// Drawn only for an actor the report lets read them: a tab whose only node is hidden is not
|
|
92
|
+
// drawn, so a registrar is not shown a tab with nothing in it.
|
|
93
|
+
{ tab: 'enrollments', label: { en: 'Enrollments', ar: 'التسجيلات' }, fields: [{ list: {
|
|
94
|
+
of: 'enrollments',
|
|
95
|
+
where: 'session',
|
|
96
|
+
columns: ['student', 'createdAt'],
|
|
97
|
+
}, when: { '$user.collections.enrollments.operations.find': { equals: true } } }] },
|
|
98
|
+
] }],
|
|
99
|
+
// Whether it is archived and why, read only: a session is archived through its own operations.
|
|
100
|
+
side: [{ ...course, fields: [...course.fields, { field: 'archived', readOnly: true }, { field: 'archiveReason', readOnly: true }] }, activity],
|
|
101
|
+
// Archived and brought back as a course is, each with its reason, before the delete.
|
|
102
|
+
danger: danger({
|
|
103
|
+
button: 'archive',
|
|
104
|
+
when: unarchived,
|
|
105
|
+
label: { en: 'Archive', ar: 'أرشفة' },
|
|
106
|
+
fields: ['reason'],
|
|
107
|
+
reload: true,
|
|
108
|
+
confirm: { title: { en: 'Archive this session?', ar: 'أرشفة هذه الجلسة؟' }, body: { en: 'It stays on the course, marked archived.', ar: 'تبقى في الدورة، معلمة بأنها مؤرشفة.' } },
|
|
109
|
+
}, {
|
|
110
|
+
button: 'unarchive',
|
|
111
|
+
when: archivedRow,
|
|
112
|
+
label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' },
|
|
113
|
+
fields: ['reason'],
|
|
114
|
+
reload: true,
|
|
115
|
+
confirm: { title: { en: 'Unarchive this session?', ar: 'إلغاء أرشفة هذه الجلسة؟' }, body: { en: 'It is open again as it was.', ar: 'تعود مفتوحة كما كانت.' } },
|
|
116
|
+
}),
|
|
117
|
+
}]),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
service: {
|
|
122
|
+
operations: {
|
|
123
|
+
...defaultOperations,
|
|
124
|
+
// One `archive` per matched row under the same access, so the mark is written by an
|
|
125
|
+
// archive alone.
|
|
126
|
+
archiveMany: { each: 'archive', steps: ({ steps }) => [archiveAll, steps.updateMany] },
|
|
127
|
+
archive: {
|
|
128
|
+
targets: { dashboard: { fields: { reason } } },
|
|
129
|
+
input: () => reasonInput('sessions.operations.archive'),
|
|
130
|
+
steps: ({ steps }) => [marks(true), steps.update],
|
|
131
|
+
},
|
|
132
|
+
unarchive: {
|
|
133
|
+
targets: { dashboard: { fields: { reason } } },
|
|
134
|
+
input: () => reasonInput('sessions.operations.unarchive'),
|
|
135
|
+
steps: ({ steps }) => [marks(false), steps.update],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
fields: {
|
|
141
|
+
...defaultFields,
|
|
142
|
+
organization,
|
|
143
|
+
course: {
|
|
144
|
+
kind: 'relationship',
|
|
145
|
+
label: { en: 'Course', ar: 'الدورة' },
|
|
146
|
+
relationTo: () => courses,
|
|
147
|
+
rules: { required: true },
|
|
148
|
+
},
|
|
149
|
+
startsAt: { kind: 'datetime', label: { en: 'Starts at', ar: 'وقت البداية' }, rules: { required: true } },
|
|
150
|
+
// A session with room for nobody is not a session, so one is the floor.
|
|
151
|
+
capacity: { kind: 'number', label: { en: 'Capacity', ar: 'السعة' }, rules: { min: 1 } },
|
|
152
|
+
venue: {
|
|
153
|
+
kind: 'text', label: { en: 'Venue', ar: 'المكان' },
|
|
154
|
+
options: [
|
|
155
|
+
{ value: 'online', label: { en: 'Online', ar: 'عبر الإنترنت' } },
|
|
156
|
+
{ value: 'campus', label: { en: 'Campus', ar: 'الحرم' } },
|
|
157
|
+
],
|
|
158
|
+
targets: { dashboard: { component: 'nativeSelect' } },
|
|
159
|
+
},
|
|
160
|
+
format: {
|
|
161
|
+
kind: 'text', label: { en: 'Format', ar: 'الصيغة' },
|
|
162
|
+
options: [
|
|
163
|
+
{ value: 'workshop', label: { en: 'Workshop', ar: 'ورشة عمل' } },
|
|
164
|
+
{ value: 'lecture', label: { en: 'Lecture', ar: 'محاضرة' } },
|
|
165
|
+
],
|
|
166
|
+
targets: { dashboard: { component: 'combobox' } },
|
|
167
|
+
},
|
|
168
|
+
// Written by `archive` and `unarchive` alone, as a course's is, and `archiveMany` runs an archive per row.
|
|
169
|
+
archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشفة' }, rules: { providedBy: { create: 'never', update: 'application' } } },
|
|
170
|
+
details: { kind: 'richText', label: { en: 'Details', ar: 'التفاصيل' } },
|
|
171
|
+
/**
|
|
172
|
+
* The enrollments that point at this session: the one kind that builds no column. `enrollments.session`
|
|
173
|
+
* holds this row's id, so a read populates the array through the enrollments kayan's own access and
|
|
174
|
+
* soft-delete filter, and a read that names no `depth` populates none of it.
|
|
175
|
+
*/
|
|
176
|
+
enrollments: {
|
|
177
|
+
kind: 'join',
|
|
178
|
+
relationTo: 'enrollments',
|
|
179
|
+
on: 'session',
|
|
180
|
+
label: { en: 'Enrollments', ar: 'التسجيلات' },
|
|
181
|
+
},
|
|
182
|
+
// Why it was last archived or brought back. Declared last, so a pushed table and a migrated one
|
|
183
|
+
// hold the column in the same place.
|
|
184
|
+
archiveReason: { kind: 'text', label: { en: 'Archive reason', ar: 'سبب الأرشفة' }, rules: { maxLength: 200, providedBy: { create: 'never', update: 'application' } } },
|
|
185
|
+
},
|
|
186
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { media, type ImageRead } from './media/media.ts'
|
|
2
|
+
|
|
3
|
+
export type { ImageRead }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The feature's kayan, built for the reads beside `media.create` that open an
|
|
7
|
+
* image, which each feature whose rows point at images brings and the asas
|
|
8
|
+
* hands in. With none, only a holder of `media.create` reads an image.
|
|
9
|
+
*/
|
|
10
|
+
export const kayan = ({ reads }: { reads: readonly ImageRead[] }) => [media({ reads })] as const
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { defaultFields, defaultOperations, folderOf, uploadFields, withUploadReference, type Where } from 'mithaq'
|
|
2
|
+
import { defineKayan, organization } from '@asas/mithaq'
|
|
3
|
+
import { readsPast } from '@asas/mithaq/roles/tenant-access.ts'
|
|
4
|
+
import { appPage, danger } from '@asas/mithaq/mithaq/pages.ts'
|
|
5
|
+
import { storageConfig } from '@asas/mithaq/storage/index.ts'
|
|
6
|
+
|
|
7
|
+
const ui = folderOf(new URL('./ui/', import.meta.url))
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One way an actor that holds no `media.create` reads an image: the images it
|
|
11
|
+
* may read, or `false` for none. Never `true`, which would open every image of
|
|
12
|
+
* the organization to whoever the read answered it for.
|
|
13
|
+
*/
|
|
14
|
+
export type ImageRead = (args: { user: unknown }) => Promise<Where | false>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The images an actor reads, inside its active organization: every image of
|
|
18
|
+
* its organization where it holds `media.create`, and otherwise only those one
|
|
19
|
+
* of `reads` answers. `media.create` is the line because a role that adds
|
|
20
|
+
* images has to find one before anything names it, in an upload field's
|
|
21
|
+
* picker. A file at `GET /api/media/file/<id>` is read through this kayan's
|
|
22
|
+
* own `findByID`, so the same answer decides who downloads it.
|
|
23
|
+
*/
|
|
24
|
+
const imagesReadBy = (reads: readonly ImageRead[]) => async ({ user }: { user: unknown }) => {
|
|
25
|
+
if (readsPast(user, 'media.create')) return true
|
|
26
|
+
const answered: unknown[] = await Promise.all(reads.map((read) => read({ user })))
|
|
27
|
+
// The type forbids it, but a cast `true` or a filter of nothing would open every image of the
|
|
28
|
+
// organization, so either throws and the actor reads none.
|
|
29
|
+
for (const [at, answer] of answered.entries()) {
|
|
30
|
+
if (answer !== false && (!answer || typeof answer !== 'object' || !Object.keys(answer).length)) {
|
|
31
|
+
throw new Error(`The image read ${reads[at]!.name || `at ${at}`} answered ${JSON.stringify(answer)}, which would open every image; a read answers false or a filter.`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const answers = answered.filter((one): one is Where => one !== false)
|
|
35
|
+
if (!answers.length) return false
|
|
36
|
+
return answers.length === 1 ? answers[0]! : { or: answers }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The organization's images: a row per file, whose object is kept where the
|
|
41
|
+
* project's storage puts it. An upload field points at one, as a course's cover does.
|
|
42
|
+
*
|
|
43
|
+
* It spreads `uploadFields`, the columns the create writes from the upload it
|
|
44
|
+
* verified and nobody writes after, and declares its own create and delete:
|
|
45
|
+
* the create takes the signed `uploadReference` that
|
|
46
|
+
* `POST /api/media/uploadInstructions` handed out and runs the project's
|
|
47
|
+
* `uploaded`, and the delete runs its `removed` after the commit. `buildAsas`
|
|
48
|
+
* refuses a create that skips the configured `uploaded`. A file is
|
|
49
|
+
* read at `GET /api/media/file/<id>` through this kayan's own `findByID`, so
|
|
50
|
+
* its organization and `reads` decide who reads it. It keeps no versions, since a file never changes once it is
|
|
51
|
+
* made; a kayan pointing at one keeps the versions of that pointer instead.
|
|
52
|
+
*/
|
|
53
|
+
export const media = ({ reads }: { reads: readonly ImageRead[] }) => defineKayan({
|
|
54
|
+
name: 'media',
|
|
55
|
+
// ANDed with the inherited rows, so it narrows and never widens.
|
|
56
|
+
access: { read: imagesReadBy(reads) },
|
|
57
|
+
messages: {
|
|
58
|
+
singular: { en: 'Image', ar: 'صورة' },
|
|
59
|
+
plural: { en: 'Images', ar: 'الصور' },
|
|
60
|
+
},
|
|
61
|
+
targets: {
|
|
62
|
+
storage: { mimeTypes: ['image/png', 'image/jpeg', 'image/webp'] },
|
|
63
|
+
// A file is named by its name wherever it stands for itself: the upload
|
|
64
|
+
// control's picker and a relationship column pointing here.
|
|
65
|
+
portal: { useAsTitle: 'filename' },
|
|
66
|
+
// The organization's images, listed and re-described. A file is made
|
|
67
|
+
// where it is used, by the upload control, and on this kayan's own New
|
|
68
|
+
// page, both through the upload dialog, which sends it before the row is
|
|
69
|
+
// made. The list draws each image's thumbnail beside its name and its size as a
|
|
70
|
+
// person reads it. The file's own columns are the runner's, so the edit
|
|
71
|
+
// page draws them read-only, with the image, its size, Download and
|
|
72
|
+
// Replace file beside the form, and above it on a phone.
|
|
73
|
+
dashboard: {
|
|
74
|
+
useAsTitle: 'filename',
|
|
75
|
+
description: { en: 'The images this organization uses, each with the words a screen reader says for it.', ar: 'الصور التي تستخدمها هذه المؤسسة، ولكل منها الكلمات التي يقرؤها قارئ الشاشة.' },
|
|
76
|
+
pages: {
|
|
77
|
+
list: { path: '/media', page: appPage([{ layout: 'table', main: [{ list: {
|
|
78
|
+
columns: [{ field: 'filename', cell: ui('preview.tsx#FileName') }, 'alt', 'mimeType', { field: 'filesize', cell: ui('preview.tsx#FileSize') }, 'createdAt'],
|
|
79
|
+
search: ['filename'],
|
|
80
|
+
sort: '-createdAt',
|
|
81
|
+
} }] }]) },
|
|
82
|
+
create: { path: '/media/new', component: ui('preview.tsx#NewImage') },
|
|
83
|
+
edit: {
|
|
84
|
+
path: '/media/:id',
|
|
85
|
+
page: appPage([{
|
|
86
|
+
layout: 'record',
|
|
87
|
+
main: [{ form: [
|
|
88
|
+
{ section: 'description', label: { en: 'Description', ar: 'الوصف' }, fields: ['alt'] },
|
|
89
|
+
{ section: 'file', label: { en: 'File', ar: 'الملف' }, fields: ['filename', 'mimeType', { row: ['width', 'height'] }] },
|
|
90
|
+
] }],
|
|
91
|
+
side: [{ component: ui('preview.tsx#MediaPreview') }],
|
|
92
|
+
danger: danger(),
|
|
93
|
+
}]),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
service: {
|
|
98
|
+
operations: {
|
|
99
|
+
...defaultOperations,
|
|
100
|
+
// The upload inside the create. Before the transaction opens, the
|
|
101
|
+
// signed reference is checked and the stored object is read back,
|
|
102
|
+
// and every file column is written from what the server measured.
|
|
103
|
+
// Then the row is made in one insert.
|
|
104
|
+
create: {
|
|
105
|
+
...defaultOperations.create,
|
|
106
|
+
input: ({ create }) => withUploadReference(create),
|
|
107
|
+
beforeTransaction: [storageConfig.uploaded],
|
|
108
|
+
},
|
|
109
|
+
// The object is removed only once the row's removal has committed,
|
|
110
|
+
// so a failure leaves an orphan for the sweep and never a row that
|
|
111
|
+
// names nothing.
|
|
112
|
+
delete: {
|
|
113
|
+
...defaultOperations.delete,
|
|
114
|
+
afterCommit: ({ afterCommit }) => [...afterCommit, storageConfig.removed],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
fields: {
|
|
120
|
+
...defaultFields,
|
|
121
|
+
organization,
|
|
122
|
+
...uploadFields,
|
|
123
|
+
// What a screen reader says for the image, in every locale, since an
|
|
124
|
+
// image with no words is no image to someone who cannot see it.
|
|
125
|
+
alt: {
|
|
126
|
+
kind: 'text',
|
|
127
|
+
label: { en: 'Alternative text', ar: 'النص البديل' },
|
|
128
|
+
rules: { localized: true, required: true, maxLength: 300 },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
})
|