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,10 @@
|
|
|
1
|
+
// The dashboard screen's one provider, folded around its shell: it marks the
|
|
2
|
+
// subtree the screen draws, so a stylesheet can scope to it.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { ProviderProps } from 'mithaq/admin'
|
|
5
|
+
|
|
6
|
+
export const ThemeProvider = ({ children }: ProviderProps<ReactNode>) => (
|
|
7
|
+
<div data-provider="theme" className="contents">
|
|
8
|
+
{children}
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// The versions are read in the browser, as the viewer, from the kayan's own
|
|
2
|
+
// `findVersions`, which answers only the versions of rows the viewer reads, each
|
|
3
|
+
// cut to the columns it reads. The page draws this only for an actor holding that
|
|
4
|
+
// key, and the restore only for one holding `restoreVersion`; the server refuses
|
|
5
|
+
// either to anyone else whatever the page drew.
|
|
6
|
+
import { useContext, useEffect, useMemo, useState } from 'react'
|
|
7
|
+
import { same, type ClientField, type ComponentProps, type Row } from 'mithaq/admin'
|
|
8
|
+
import { FormContext, useAccess, useClient, useKayan, useLocale, useRecord, useScreen } from 'mithaq/admin/react'
|
|
9
|
+
import {
|
|
10
|
+
AlertDialog,
|
|
11
|
+
AlertDialogAction,
|
|
12
|
+
AlertDialogCancel,
|
|
13
|
+
AlertDialogContent,
|
|
14
|
+
AlertDialogDescription,
|
|
15
|
+
AlertDialogFooter,
|
|
16
|
+
AlertDialogHeader,
|
|
17
|
+
AlertDialogTitle,
|
|
18
|
+
} from '@/components/ui/alert-dialog'
|
|
19
|
+
import { Badge } from '@/components/ui/badge'
|
|
20
|
+
import { Button } from '@/components/ui/button'
|
|
21
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
22
|
+
import { Label } from '@/components/ui/label'
|
|
23
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
24
|
+
import { Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
|
25
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
26
|
+
import { blocksOf, richTextPlainText } from 'mithaq-admin/rich-text-line.ts'
|
|
27
|
+
import { cellFor, useComponents } from 'mithaq-admin/components.tsx'
|
|
28
|
+
import type { FindApi, FoundPage } from 'mithaq-admin/control-props.ts'
|
|
29
|
+
import { labelFor, titleOf } from 'mithaq-admin/copy.ts'
|
|
30
|
+
import { formatDate } from 'mithaq-admin/format.tsx'
|
|
31
|
+
import { doneWords } from 'mithaq-admin/form-rules.tsx'
|
|
32
|
+
import { carry } from 'mithaq-admin/toast.tsx'
|
|
33
|
+
import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
|
|
34
|
+
import { endonym } from 'mithaq-admin/locale-menu.ts'
|
|
35
|
+
import { CloseButton } from 'mithaq-admin/record-frame.tsx'
|
|
36
|
+
import { useRelationshipTitles } from 'mithaq-admin/relationship-cell.tsx'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a version's column draws as a dash: absent, `null`, an empty string or
|
|
40
|
+
* an empty list, as the audit log's activity panel reads it. Its own here, so a
|
|
41
|
+
* project without the audit log keeps its versions.
|
|
42
|
+
*/
|
|
43
|
+
const empty = (value: unknown) => value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)
|
|
44
|
+
|
|
45
|
+
/** One version as `findVersions` answers it. */
|
|
46
|
+
export type Version = {
|
|
47
|
+
id: string
|
|
48
|
+
latest: boolean
|
|
49
|
+
autosave: boolean
|
|
50
|
+
updatedAt: string | Date
|
|
51
|
+
version: Row
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How many versions one read asks for, and how many more each "Show older" adds. */
|
|
55
|
+
const page = 10
|
|
56
|
+
|
|
57
|
+
/** The four status labels, each under its `version` key with the English it falls back to. */
|
|
58
|
+
const labels = {
|
|
59
|
+
currentDraft: 'Current draft',
|
|
60
|
+
draft: 'Draft',
|
|
61
|
+
currentlyPublished: 'Currently published',
|
|
62
|
+
previouslyPublished: 'Previously published',
|
|
63
|
+
} as const
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which of the four labels a version wears, over the row's versions newest first.
|
|
67
|
+
* A draft is the current draft when it is the latest version, since then no
|
|
68
|
+
* published version is newer than it. A published version is the currently
|
|
69
|
+
* published one when it is the newest published version: the row always holds the
|
|
70
|
+
* last published copy and nothing unpublishes it. The newest page is always read
|
|
71
|
+
* first, so that version is on it whenever any published version is.
|
|
72
|
+
*/
|
|
73
|
+
const labelOf = (version: Version, versions: readonly Version[]): keyof typeof labels | undefined =>
|
|
74
|
+
// A reader without the `_status` column gets it cut from every version, and
|
|
75
|
+
// a status nobody can read is no status to draw.
|
|
76
|
+
!('_status' in version.version)
|
|
77
|
+
? undefined
|
|
78
|
+
: version.version._status === 'draft'
|
|
79
|
+
? version.latest ? 'currentDraft' : 'draft'
|
|
80
|
+
: version.id === versions.find((one) => one.version._status !== 'draft')?.id ? 'currentlyPublished' : 'previouslyPublished'
|
|
81
|
+
|
|
82
|
+
/** The columns every kayan of this project carries and no person edits, and the status the pill already says. */
|
|
83
|
+
const technical = new Set(['id', 'organization', 'createdAt', 'updatedAt', '_status'])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The words of each block of `side`, and whether `other` holds no block of the
|
|
87
|
+
* same value, as a block is the unit a person reads a rich text change by. A block
|
|
88
|
+
* is held by its whole value, so one whose format or link changed is marked though
|
|
89
|
+
* its words did not.
|
|
90
|
+
*/
|
|
91
|
+
const changedBlocks = ({ side, other }: { side: unknown; other: unknown }) => {
|
|
92
|
+
const left = new Map<string, number>()
|
|
93
|
+
for (const block of blocksOf(other)) {
|
|
94
|
+
const key = JSON.stringify(block)
|
|
95
|
+
left.set(key, (left.get(key) ?? 0) + 1)
|
|
96
|
+
}
|
|
97
|
+
return blocksOf(side).flatMap((block) => {
|
|
98
|
+
const key = JSON.stringify(block)
|
|
99
|
+
const held = left.get(key) ?? 0
|
|
100
|
+
if (held) left.set(key, held - 1)
|
|
101
|
+
const text = richTextPlainText(block).trim()
|
|
102
|
+
return text ? [{ text, changed: held === 0 }] : []
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const entriesOf = (value: unknown) => (value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [])
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The panel an edit page names as a component node. It reads the row the page
|
|
110
|
+
* draws and its kayan off the screen, so one file serves every kayan that keeps
|
|
111
|
+
* drafts.
|
|
112
|
+
*/
|
|
113
|
+
export function Versions(_: ComponentProps) {
|
|
114
|
+
const { t, locale } = useLocale()
|
|
115
|
+
const kayan = useKayan()
|
|
116
|
+
const record = useRecord()
|
|
117
|
+
const client = useClient()
|
|
118
|
+
const id = record?.id === undefined ? undefined : String(record.id)
|
|
119
|
+
// How many saves have landed on the form this panel sits in, an autosave
|
|
120
|
+
// included. A draft save never moves the row's own `updatedAt`, so this is what
|
|
121
|
+
// tells a panel that stayed mounted through one that its list is stale.
|
|
122
|
+
const saves = useContext(FormContext)?.saves ?? 0
|
|
123
|
+
// The row as this page last drew it: a save moves its `updatedAt` or bumps
|
|
124
|
+
// `saves`, and the panel reads its versions again.
|
|
125
|
+
const seen = `${id}@${String(record?.updatedAt ?? '')}@${saves}`
|
|
126
|
+
const [limit, setLimit] = useState(page)
|
|
127
|
+
const [read, setRead] = useState<{ seen: string; docs: Version[]; more: boolean } | { seen: string; failed: true }>()
|
|
128
|
+
const [opened, setOpened] = useState<Version>()
|
|
129
|
+
|
|
130
|
+
// The client is left out of the dependencies: the host makes a new one on every
|
|
131
|
+
// render, so following it would read the same page again on every render.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!id) return
|
|
134
|
+
let live = true
|
|
135
|
+
;(client({ kayan: kayan.name, operation: 'findVersions', input: { where: { parent: { equals: id } }, sort: '-updatedAt', limit } }) as Promise<{ docs: Version[]; hasNextPage: boolean }>).then(
|
|
136
|
+
(found) => live && setRead({ seen, docs: found.docs, more: found.hasNextPage }),
|
|
137
|
+
() => live && setRead({ seen, failed: true }),
|
|
138
|
+
)
|
|
139
|
+
return () => {
|
|
140
|
+
live = false
|
|
141
|
+
}
|
|
142
|
+
}, [kayan.name, seen, limit])
|
|
143
|
+
|
|
144
|
+
const shown = read?.seen === seen ? read : undefined
|
|
145
|
+
const when = (at: string | Date) => formatDate({ value: at, locale }) ?? String(at)
|
|
146
|
+
return (
|
|
147
|
+
<div data-panel="versions" className="grid gap-4">
|
|
148
|
+
{shown === undefined ? (
|
|
149
|
+
<p className="text-muted-foreground text-sm">{t('version.loading', 'Loading versions…')}</p>
|
|
150
|
+
) : 'failed' in shown ? (
|
|
151
|
+
<p role="alert" className="text-destructive text-sm">{t('version.failed', 'The versions could not be read.')}</p>
|
|
152
|
+
) : !shown.docs.length ? (
|
|
153
|
+
<p className="text-muted-foreground text-sm">{t('version.none', 'No versions yet.')}</p>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
<Table>
|
|
157
|
+
<TableHeader>
|
|
158
|
+
<TableRow>
|
|
159
|
+
<TableHead>{t('version.updatedAt', 'Updated at')}</TableHead>
|
|
160
|
+
<TableHead>{t('version.status', 'Status')}</TableHead>
|
|
161
|
+
</TableRow>
|
|
162
|
+
</TableHeader>
|
|
163
|
+
<TableBody>
|
|
164
|
+
{shown.docs.map((version) => (
|
|
165
|
+
<TableRow key={version.id} data-version={version.id}>
|
|
166
|
+
<TableCell>
|
|
167
|
+
{/* Underlined and in the link colour at rest, since a phone has
|
|
168
|
+
no hover to show that the date opens the comparison, and
|
|
169
|
+
44 pixels tall. */}
|
|
170
|
+
<button type="button" data-button="open-version" className="text-primary inline-flex min-h-11 cursor-pointer items-center underline underline-offset-4" onClick={() => setOpened(version)}>
|
|
171
|
+
<time dateTime={new Date(version.updatedAt).toISOString()}>{when(version.updatedAt)}</time>
|
|
172
|
+
</button>
|
|
173
|
+
</TableCell>
|
|
174
|
+
<TableCell>
|
|
175
|
+
<div className="flex flex-wrap gap-1">
|
|
176
|
+
{version.autosave ? <Badge variant="outline">{t('version.autosave', 'Autosave')}</Badge> : null}
|
|
177
|
+
<Pill version={version} versions={shown.docs} />
|
|
178
|
+
</div>
|
|
179
|
+
</TableCell>
|
|
180
|
+
</TableRow>
|
|
181
|
+
))}
|
|
182
|
+
</TableBody>
|
|
183
|
+
</Table>
|
|
184
|
+
{shown.more ? (
|
|
185
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setLimit(limit + page)}>
|
|
186
|
+
{t('version.more', 'Show older')}
|
|
187
|
+
</Button>
|
|
188
|
+
) : null}
|
|
189
|
+
{opened && id ? <Compare key={opened.id} version={opened} versions={shown.docs} parent={id} onClose={() => setOpened(undefined)} /> : null}
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A version's status pill, the currently published one standing out. */
|
|
197
|
+
function Pill({ version, versions }: { version: Version; versions: readonly Version[] }) {
|
|
198
|
+
const { t } = useLocale()
|
|
199
|
+
const label = labelOf(version, versions)
|
|
200
|
+
return label ? <Badge variant={label === 'currentlyPublished' ? 'default' : 'outline'}>{t(`version.${label}`, labels[label])}</Badge> : null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The sheet over the list: the version, and the one it is compared against, by
|
|
205
|
+
* default the newest one older than it, field by field, each value drawn by the
|
|
206
|
+
* field's own cell and a localized field one row per locale, modified fields only
|
|
207
|
+
* until the viewer asks for all. A column the viewer may not read is in neither
|
|
208
|
+
* snapshot, so it draws no row.
|
|
209
|
+
*/
|
|
210
|
+
function Compare({ version, versions, parent, onClose }: { version: Version; versions: readonly Version[]; parent: string; onClose: () => void }) {
|
|
211
|
+
const { t, locale, dir } = useLocale()
|
|
212
|
+
const kayan = useKayan()
|
|
213
|
+
const client = useClient()
|
|
214
|
+
const screen = useScreen()
|
|
215
|
+
const restores = useAccess().collections?.[kayan.name]?.operations?.restoreVersion === true
|
|
216
|
+
/** Whether the kayan's restore takes `expectedUpdatedAt`, as the form this panel sits in says. */
|
|
217
|
+
const takes = useContext(FormContext)?.expectedUpdatedAtOn?.includes('restoreVersion') === true
|
|
218
|
+
/** The version compared against, or `''` for the one before. */
|
|
219
|
+
const [against, setAgainst] = useState('')
|
|
220
|
+
const [modifiedOnly, setModifiedOnly] = useState(true)
|
|
221
|
+
/** The version before this one, read once: the list holds every other version a person may pick. */
|
|
222
|
+
const [prior, setPrior] = useState<{ version: Version | null } | { failed: true }>()
|
|
223
|
+
/** The restore waiting on its confirm, and whether it restores as a draft. */
|
|
224
|
+
const [confirming, setConfirming] = useState<{ draft: boolean }>()
|
|
225
|
+
const [restoring, setRestoring] = useState(false)
|
|
226
|
+
/** A restore the server refused, drawn in the sheet, which stays open. */
|
|
227
|
+
const [restoreFailed, setRestoreFailed] = useState<unknown>(undefined)
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
let live = true
|
|
231
|
+
const older = { and: [{ parent: { equals: parent } }, { updatedAt: { less_than: new Date(version.updatedAt).toISOString() } }] }
|
|
232
|
+
;(client({ kayan: kayan.name, operation: 'findVersions', input: { where: older, sort: '-updatedAt', limit: 1 } }) as Promise<{ docs: Version[] }>).then(
|
|
233
|
+
(found) => live && setPrior({ version: found.docs[0] ?? null }),
|
|
234
|
+
() => live && setPrior({ failed: true }),
|
|
235
|
+
)
|
|
236
|
+
return () => {
|
|
237
|
+
live = false
|
|
238
|
+
}
|
|
239
|
+
}, [version.id])
|
|
240
|
+
|
|
241
|
+
const previous = prior && 'version' in prior ? prior.version : null
|
|
242
|
+
const from = against ? versions.find((one) => one.id === against) ?? null : previous
|
|
243
|
+
const rows = kayan.fields
|
|
244
|
+
.filter((field) => field.kind !== 'join' && !technical.has(field.name) && (field.name in version.version || (!!from && field.name in from.version)))
|
|
245
|
+
.flatMap((field): { key: string; field: ClientField; before: unknown; after: unknown; code?: string }[] => {
|
|
246
|
+
const before = from?.version[field.name]
|
|
247
|
+
const after = version.version[field.name]
|
|
248
|
+
if (field.rules.localized !== true) return [{ key: field.name, field, before, after }]
|
|
249
|
+
// Each locale's value alone, drawn by the field's cell as a plain value.
|
|
250
|
+
const plain: ClientField = { ...field, rules: { ...field.rules, localized: false } }
|
|
251
|
+
const codes = [...new Set([...entriesOf(before), ...entriesOf(after)])]
|
|
252
|
+
return codes.map((code) => ({
|
|
253
|
+
key: `${field.name}.${code}`,
|
|
254
|
+
field: plain,
|
|
255
|
+
before: (before as Record<string, unknown> | undefined)?.[code],
|
|
256
|
+
after: (after as Record<string, unknown> | undefined)?.[code],
|
|
257
|
+
code,
|
|
258
|
+
}))
|
|
259
|
+
})
|
|
260
|
+
// Whether the two sides differ, once per row, which both the filter and the row's mark read.
|
|
261
|
+
.map((row) => ({ ...row, changed: !(same(row.before, row.after) || (empty(row.before) && empty(row.after))) }))
|
|
262
|
+
.filter((row) => !modifiedOnly || row.changed)
|
|
263
|
+
|
|
264
|
+
// Every kayan this screen crosses, so a relationship's value is named the way
|
|
265
|
+
// the list names it.
|
|
266
|
+
const kayans = useMemo(() => Object.values(screen.kayan), [screen])
|
|
267
|
+
const api = useMemo<FindApi>(
|
|
268
|
+
() => Object.fromEntries(kayans.map((one) => [one.name, { find: (input: Row) => client({ kayan: one.name, operation: 'find', input }) as Promise<FoundPage> }])),
|
|
269
|
+
[kayans],
|
|
270
|
+
)
|
|
271
|
+
const titles = useRelationshipTitles({ fields: kayan.fields, docs: [version.version, from?.version ?? {}], context: { api, client: kayans, locale } })
|
|
272
|
+
const { cells } = useComponents()
|
|
273
|
+
const copy = { t, locale }
|
|
274
|
+
// A rich text value whole, a block to a line, where its list cell draws the
|
|
275
|
+
// first block alone: a change further down would read as no change. A block the
|
|
276
|
+
// other side does not hold is marked, as removed (`del`) on the side compared
|
|
277
|
+
// against and as added (`ins`) on the side viewed: struck through and underlined
|
|
278
|
+
// as well as tinted, so the mark does not rest on colour alone.
|
|
279
|
+
const drawn = (field: ClientField, value: unknown, other: unknown, mark: 'del' | 'ins') => {
|
|
280
|
+
if (empty(value)) return '—'
|
|
281
|
+
if (field.kind !== 'richText') return cellFor({ cells, field })({ kayan, field, value, t, locale, titles })
|
|
282
|
+
const blocks = changedBlocks({ side: value, other })
|
|
283
|
+
// A value of images or empty blocks alone has no words, and reads as empty.
|
|
284
|
+
if (!blocks.length) return '—'
|
|
285
|
+
return blocks.map(({ text, changed }, at) => {
|
|
286
|
+
const Mark = mark
|
|
287
|
+
return <p key={at} dir="auto" className="whitespace-pre-line">{changed ? <Mark data-block-changed="" className={mark === 'del' ? 'bg-destructive/15 line-through' : 'bg-primary/15 underline'}>{text}</Mark> : text}</p>
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
const when = (at: string | Date) => formatDate({ value: at, locale }) ?? String(at)
|
|
291
|
+
const named = (one: Version) => {
|
|
292
|
+
const label = labelOf(one, versions)
|
|
293
|
+
return label ? `${when(one.updatedAt)} · ${t(`version.${label}`, labels[label])}` : when(one.updatedAt)
|
|
294
|
+
}
|
|
295
|
+
const options = [
|
|
296
|
+
// With its date, so the select says which version it compares against.
|
|
297
|
+
{ value: '', label: previous ? `${t('version.previousVersion', 'Previous version')} · ${when(previous.updatedAt)}` : (t('version.previousVersion', 'Previous version') ?? '') },
|
|
298
|
+
// The version before is offered once, as "Previous version".
|
|
299
|
+
...versions.filter((one) => one.id !== version.id && one.id !== previous?.id).map((one) => ({ value: one.id, label: named(one) })),
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Puts the version back, onto the row or as a new draft, and draws the page
|
|
304
|
+
* again from the server, so the form reads what the restore wrote. A refused or
|
|
305
|
+
* failed restore keeps the sheet open and draws its own sentence and the
|
|
306
|
+
* server's (`refusalKeyOf`, which the form's own banner reads the same way).
|
|
307
|
+
*/
|
|
308
|
+
const restore = async (draft: boolean) => {
|
|
309
|
+
setRestoring(true)
|
|
310
|
+
setRestoreFailed(undefined)
|
|
311
|
+
try {
|
|
312
|
+
// The instant of the latest version this panel read, where the kayan's
|
|
313
|
+
// restore takes it, so a restore over a newer draft another person saved
|
|
314
|
+
// since is refused 409 rather than burying it.
|
|
315
|
+
const read = takes ? versions.find((one) => one.latest)?.version.updatedAt : undefined
|
|
316
|
+
await client({ kayan: kayan.name, operation: 'restoreVersion', input: { id: version.id, ...(read !== undefined && { expectedUpdatedAt: read }) }, draft })
|
|
317
|
+
carry(doneWords({ kayan, copy: { t, locale }, operation: { name: 'restoreVersion' } }))
|
|
318
|
+
window.location.reload()
|
|
319
|
+
} catch (thrown) {
|
|
320
|
+
setRestoring(false)
|
|
321
|
+
setConfirming(undefined)
|
|
322
|
+
setRestoreFailed(thrown)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const restoreFailedKey = restoreFailed === undefined ? undefined : refusalKeyOf(restoreFailed)
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
|
329
|
+
{/* The primitive's `data-[side=*]:w-3/4` is outranked by naming the same
|
|
330
|
+
side, so the sheet is the phone's whole width. */}
|
|
331
|
+
<SheetContent showCloseButton={false} side={dir === 'rtl' ? 'left' : 'right'} className="w-full overflow-y-auto data-[side=left]:w-full data-[side=right]:w-full data-[side=left]:sm:max-w-2xl data-[side=right]:sm:max-w-2xl">
|
|
332
|
+
<SheetHeader>
|
|
333
|
+
<SheetTitle>{t('version.compareVersions', 'Compare versions')}</SheetTitle>
|
|
334
|
+
</SheetHeader>
|
|
335
|
+
<div className="grid gap-4 px-4">
|
|
336
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
337
|
+
<div className="grid gap-1.5">
|
|
338
|
+
<span className="text-muted-foreground text-xs">{t('version.comparingAgainst', 'Comparing against')}</span>
|
|
339
|
+
<Select value={against} disabled={prior === undefined} onValueChange={(value) => setAgainst(String(value ?? ''))}>
|
|
340
|
+
<SelectTrigger className="w-full max-sm:data-[size=default]:h-11"><SelectValue>{options.find((one) => one.value === against)?.label}</SelectValue></SelectTrigger>
|
|
341
|
+
<SelectContent>
|
|
342
|
+
{options.map((one) => (
|
|
343
|
+
<SelectItem key={one.value} value={one.value}>{one.label}</SelectItem>
|
|
344
|
+
))}
|
|
345
|
+
</SelectContent>
|
|
346
|
+
</Select>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="grid gap-1.5">
|
|
349
|
+
<span className="text-muted-foreground text-xs">{t('version.currentlyViewing', 'Currently viewing')}</span>
|
|
350
|
+
<div className="flex flex-wrap items-center gap-1 text-sm">
|
|
351
|
+
<time dateTime={new Date(version.updatedAt).toISOString()}>{when(version.updatedAt)}</time>
|
|
352
|
+
{version.autosave ? <Badge variant="outline">{t('version.autosave', 'Autosave')}</Badge> : null}
|
|
353
|
+
<Pill version={version} versions={versions} />
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
<div className="flex items-center gap-2">
|
|
358
|
+
<Checkbox id={`${version.id}-modified-only`} data-modified-only checked={modifiedOnly} onCheckedChange={(checked) => setModifiedOnly(checked === true)} />
|
|
359
|
+
<Label htmlFor={`${version.id}-modified-only`}>{t('version.modifiedOnly', 'Modified only')}</Label>
|
|
360
|
+
</div>
|
|
361
|
+
{prior === undefined ? (
|
|
362
|
+
<p className="text-muted-foreground text-sm">{t('version.loading', 'Loading versions…')}</p>
|
|
363
|
+
) : 'failed' in prior && !against ? (
|
|
364
|
+
<p role="alert" className="text-destructive text-sm">{t('version.failed', 'The versions could not be read.')}</p>
|
|
365
|
+
) : !rows.length ? (
|
|
366
|
+
<p className="text-muted-foreground text-sm">{t('version.noReadableChange', 'No field you may read changed.')}</p>
|
|
367
|
+
) : (
|
|
368
|
+
<ul className="grid gap-3 text-sm">
|
|
369
|
+
{/* Which side is which, over the two columns, since on a phone
|
|
370
|
+
the two labels above stack in one. */}
|
|
371
|
+
<li aria-hidden="true" className="text-muted-foreground grid grid-cols-2 gap-2 text-xs">
|
|
372
|
+
<span>{t('version.comparingAgainst', 'Comparing against')}</span>
|
|
373
|
+
<span>{t('version.currentlyViewing', 'Currently viewing')}</span>
|
|
374
|
+
</li>
|
|
375
|
+
{rows.map((row) => {
|
|
376
|
+
// What the "Modified only" filter keeps, so with every field shown
|
|
377
|
+
// the rows that differ say so in words and in a rule at their start.
|
|
378
|
+
const { changed } = row
|
|
379
|
+
return (
|
|
380
|
+
<li key={row.key} data-diff={row.key} data-changed={changed ? '' : undefined} className={changed ? 'border-primary grid gap-1 border-s-2 ps-2' : 'grid gap-1'}>
|
|
381
|
+
<span className="flex items-center gap-1.5 font-medium">
|
|
382
|
+
{labelFor({ kayan, field: row.field, copy })}
|
|
383
|
+
{row.code ? <Badge variant="outline">{endonym(row.code)}</Badge> : null}
|
|
384
|
+
{changed && !modifiedOnly ? <Badge data-badge="changed">{t('version.fieldChanged', 'Changed')}</Badge> : null}
|
|
385
|
+
</span>
|
|
386
|
+
<div className="grid grid-cols-2 gap-2">
|
|
387
|
+
<div data-before className="bg-muted/50 min-w-0 rounded-md px-2 py-1 break-words">{drawn(row.field, row.before, row.after, 'del')}</div>
|
|
388
|
+
<div data-after className="bg-muted min-w-0 rounded-md px-2 py-1 break-words">{drawn(row.field, row.after, row.before, 'ins')}</div>
|
|
389
|
+
</div>
|
|
390
|
+
</li>
|
|
391
|
+
)
|
|
392
|
+
})}
|
|
393
|
+
</ul>
|
|
394
|
+
)}
|
|
395
|
+
{restoreFailed !== undefined ? (
|
|
396
|
+
<>
|
|
397
|
+
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
398
|
+
{t('version.problemRestoringVersion', 'There was a problem restoring this version')}
|
|
399
|
+
</p>
|
|
400
|
+
{restoreFailedKey ? (
|
|
401
|
+
<p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
402
|
+
{t(restoreFailedKey, restoreFailedKey)}
|
|
403
|
+
</p>
|
|
404
|
+
) : null}
|
|
405
|
+
</>
|
|
406
|
+
) : null}
|
|
407
|
+
</div>
|
|
408
|
+
{restores && labelOf(version, versions) !== 'currentlyPublished' ? (
|
|
409
|
+
// Not offered for the version the row already holds, where a restore would add a version and an
|
|
410
|
+
// activity line and change nothing. Sticky at the popup's foot, so Restore stays in reach however long the diff is.
|
|
411
|
+
<SheetFooter className="bg-inherit sticky bottom-0 flex-row flex-wrap justify-end border-t">
|
|
412
|
+
{'_status' in version.version && version.version._status !== 'draft' ? (
|
|
413
|
+
<Button type="button" variant="outline" className="max-sm:h-11" data-button="restore-as-draft" disabled={restoring} onClick={() => setConfirming({ draft: true })}>
|
|
414
|
+
{t('version.restoreAsDraft', 'Restore as draft')}
|
|
415
|
+
</Button>
|
|
416
|
+
) : null}
|
|
417
|
+
<Button type="button" className="max-sm:h-11" data-button="restore" disabled={restoring} onClick={() => setConfirming({ draft: false })}>
|
|
418
|
+
{t('version.restoreThisVersion', 'Restore this version')}
|
|
419
|
+
</Button>
|
|
420
|
+
</SheetFooter>
|
|
421
|
+
) : null}
|
|
422
|
+
<AlertDialog open={!!confirming} onOpenChange={(open) => !open && setConfirming(undefined)}>
|
|
423
|
+
<AlertDialogContent>
|
|
424
|
+
<AlertDialogHeader>
|
|
425
|
+
<AlertDialogTitle>{t('version.confirmVersionRestoration', 'Confirm version restoration')}</AlertDialogTitle>
|
|
426
|
+
<AlertDialogDescription>
|
|
427
|
+
{(t('version.aboutToRestore', 'You are about to restore this {label} to the state it was in on {versionDate}.') ?? '')
|
|
428
|
+
.replace('{label}', titleOf({ kayan, copy }))
|
|
429
|
+
.replace('{versionDate}', when(version.updatedAt))}
|
|
430
|
+
</AlertDialogDescription>
|
|
431
|
+
</AlertDialogHeader>
|
|
432
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
433
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel')}</AlertDialogCancel>
|
|
434
|
+
<AlertDialogAction data-button="confirm-restore" disabled={restoring} onClick={() => confirming && void restore(confirming.draft)}>
|
|
435
|
+
{confirming?.draft ? t('version.restoreAsDraft', 'Restore as draft') : t('version.restore', 'Restore')}
|
|
436
|
+
</AlertDialogAction>
|
|
437
|
+
</AlertDialogFooter>
|
|
438
|
+
</AlertDialogContent>
|
|
439
|
+
</AlertDialog>
|
|
440
|
+
{/* The registry's close, named in the page's language, in place of the
|
|
441
|
+
primitive's English one. */}
|
|
442
|
+
<CloseButton Close={SheetClose} label={t('admin.close', 'Close') ?? 'Close'} />
|
|
443
|
+
</SheetContent>
|
|
444
|
+
</Sheet>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What `/portal` itself draws for the courses example, in the portal's own look: a greeting, the viewer's next
|
|
2
|
+
// session, and a few courses on offer it holds no seat in yet. It is the asas's own, since a screen's root page is
|
|
3
|
+
// drawn from the project, and draws with the courses feature's portal pieces.
|
|
4
|
+
import { useSyncExternalStore } from 'react'
|
|
5
|
+
import { CalendarDays, MapPin } from 'lucide-react'
|
|
6
|
+
import { useQuery } from '@tanstack/react-query'
|
|
7
|
+
import { useClient, useLocale, useScreen, useUser } from 'mithaq/admin/react'
|
|
8
|
+
import { buttonVariants } from '@/components/ui/button'
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
10
|
+
import { PortalShell, display } from '@asas/courses/portal/ui/shell.tsx'
|
|
11
|
+
import { CardsLoading, Cover, CourseCard, ReadFailed, usePlace } from '@asas/courses/portal/ui/parts.tsx'
|
|
12
|
+
import { byWhen, partOfDay, startsIn, whenOf } from '@asas/courses/portal/ui/rules.ts'
|
|
13
|
+
import { catalogueOf, readSeats } from '@asas/courses/portal/ui/reads.ts'
|
|
14
|
+
|
|
15
|
+
/** How many courses the home offers before its link to the rest. */
|
|
16
|
+
const offered = 3
|
|
17
|
+
|
|
18
|
+
/** The hour on the viewer's own clock, and none on the server, which knows neither the clock nor the zone. */
|
|
19
|
+
const useHour = () => useSyncExternalStore(() => () => {}, () => new Date().getHours(), () => undefined)
|
|
20
|
+
|
|
21
|
+
export function Home() {
|
|
22
|
+
const { locale, t } = useLocale()
|
|
23
|
+
const client = useClient()
|
|
24
|
+
const screen = useScreen()
|
|
25
|
+
const user = useUser()
|
|
26
|
+
const place = usePlace()
|
|
27
|
+
const hour = useHour()
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
// The viewer's own seats, whoever it is: an instructor reads every seat, and its next session is still its own.
|
|
30
|
+
const mine = useQuery({
|
|
31
|
+
queryKey: ['portal-mine', user.id],
|
|
32
|
+
queryFn: () => readSeats({ client, maxLimit: screen.maxLimit, where: { student: { equals: user.id } } }),
|
|
33
|
+
enabled: !!user.id,
|
|
34
|
+
})
|
|
35
|
+
const catalogue = useQuery({ queryKey: ['portal-catalogue'], queryFn: () => catalogueOf({ client, maxLimit: screen.maxLimit, now }), staleTime: 30_000 })
|
|
36
|
+
const next = mine.data && byWhen(mine.data, now).upcoming[0]
|
|
37
|
+
const held = new Set(mine.data?.map((one) => String(one.course?.id)))
|
|
38
|
+
// A course the student may enroll itself in, with a session to come, and no seat of its own in it yet.
|
|
39
|
+
const open = catalogue.data?.courses.filter((course) => course.selfEnroll === true && !held.has(String(course.id)) && catalogue.data.next.has(String(course.id))).slice(0, offered)
|
|
40
|
+
// One sentence per locale, so each says the name where its grammar puts it.
|
|
41
|
+
const greeting = (hour === undefined ? t('portal.welcome', 'Welcome, {name}') : t(`portal.greeting.${partOfDay(hour)}`, 'Hello, {name}'))!.replace('{name}', () => user.name)
|
|
42
|
+
return (
|
|
43
|
+
<PortalShell
|
|
44
|
+
regions={{
|
|
45
|
+
header: <h1 className={`${display} text-3xl leading-tight sm:text-4xl`}>{greeting}</h1>,
|
|
46
|
+
main: (
|
|
47
|
+
<div data-dashboard="portal" className="grid gap-8">
|
|
48
|
+
{mine.isPending && user.id ? <Skeleton className="h-52 rounded-3xl" /> : next ? (
|
|
49
|
+
<section aria-labelledby="next" className="bg-primary text-primary-foreground grid overflow-hidden rounded-3xl md:grid-cols-[3fr_2fr]">
|
|
50
|
+
<div className="grid content-start gap-3.5 p-5 sm:p-8">
|
|
51
|
+
<h2 id="next" className="text-sm opacity-85">{t('portal.yourNext', 'Your next session')}</h2>
|
|
52
|
+
<p className={`${display} text-2xl leading-tight sm:text-3xl`}>{String(next.course?.title ?? '')}</p>
|
|
53
|
+
<div className="grid gap-1.5 text-[15px]">
|
|
54
|
+
<span className="flex items-center gap-2"><CalendarDays aria-hidden className="size-4.5 shrink-0" />{whenOf({ value: next.startsAt, locale })}</span>
|
|
55
|
+
{place(next.session) ? <span className="flex items-center gap-2"><MapPin aria-hidden className="size-4.5 shrink-0" />{place(next.session)}</span> : null}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
|
58
|
+
<span className="text-sm opacity-85">{t('portal.startsIn', 'Starts {when}')!.replace('{when}', () => startsIn({ value: next.startsAt, now, locale }) ?? '')}</span>
|
|
59
|
+
{next.course ? (
|
|
60
|
+
<a href={`${screen.path}/courses/${encodeURIComponent(String(next.course.id))}`} className={buttonVariants({ variant: 'secondary', className: 'bg-card text-primary h-11 rounded-full px-5 font-semibold' })}>
|
|
61
|
+
{t('portal.viewCourse', 'View course')}
|
|
62
|
+
</a>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<Cover course={next.course} cover={next.cover} className="hidden h-full min-h-60 w-full md:block" />
|
|
67
|
+
</section>
|
|
68
|
+
) : null}
|
|
69
|
+
<section aria-labelledby="join" className="grid gap-4">
|
|
70
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
71
|
+
<h2 id="join" className={`${display} text-xl sm:text-2xl`}>{t('portal.canJoin', 'Courses you can join')}</h2>
|
|
72
|
+
<a href={`${screen.path}/courses`} className="text-primary text-[15px] font-medium underline-offset-4 hover:underline">{t('portal.seeAll', 'See all courses')}</a>
|
|
73
|
+
</div>
|
|
74
|
+
{catalogue.isError ? <ReadFailed retry={() => void catalogue.refetch()} /> : !open ? <CardsLoading /> : open.length ? (
|
|
75
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-5">
|
|
76
|
+
{open.map((course) => (
|
|
77
|
+
<CourseCard
|
|
78
|
+
key={String(course.id)}
|
|
79
|
+
course={course}
|
|
80
|
+
cover={catalogue.data!.covers.get(String(course.cover))}
|
|
81
|
+
next={catalogue.data!.next.get(String(course.id))}
|
|
82
|
+
tags={catalogue.data!.tags}
|
|
83
|
+
href={`${screen.path}/courses/${encodeURIComponent(String(course.id))}`}
|
|
84
|
+
/>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<p className="text-muted-foreground text-sm">{t('portal.noneToJoin', 'No course has a session coming up just now.')}</p>
|
|
89
|
+
)}
|
|
90
|
+
</section>
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What `/portal` itself draws in a project without the courses example, whose
|
|
2
|
+
// portal draws its own home: a greeting, then the dashboard home's figure card
|
|
3
|
+
// for each list below, with how many rows of it the viewer may read and a link
|
|
4
|
+
// to it. A list the nav does not offer the viewer has no card, and a list whose
|
|
5
|
+
// count the viewer may not read, or whose count failed, shows a dash in place
|
|
6
|
+
// of the figure.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import type { LoaderTools, PageProps } from 'mithaq/admin'
|
|
9
|
+
import { useLocale, useScreen, useUser } from 'mithaq/admin/react'
|
|
10
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
11
|
+
import { ScreenNav, navLabel } from '@asas/mithaq/ui/screen-nav.tsx'
|
|
12
|
+
import { PortalTopShell } from '@asas/mithaq/ui/top-shell.tsx'
|
|
13
|
+
|
|
14
|
+
/** The lists the home counts, each with its card's icon and the rows its list is held to. */
|
|
15
|
+
// A list of the project's own goes here, as `['tags', <Tags className="size-4" />]`.
|
|
16
|
+
const figures: readonly [kayan: string, icon: ReactNode, where?: object][] = []
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How many rows of each list the viewer may count, read as the viewer, keyed
|
|
20
|
+
* by its kayan. A kayan that keeps drafts is counted as its list reads it, the
|
|
21
|
+
* latest draft of every row for a reader of `findVersions`, with `find` and a
|
|
22
|
+
* page of one row, since `count` reads only the published ones.
|
|
23
|
+
*/
|
|
24
|
+
export const loader = async ({ client, access }: LoaderTools): Promise<Record<string, number | undefined>> =>
|
|
25
|
+
Object.fromEntries(
|
|
26
|
+
await Promise.all(
|
|
27
|
+
figures.map(async ([kayan, , where]) => {
|
|
28
|
+
const operations = access.collections?.[kayan]?.operations
|
|
29
|
+
if (operations?.count !== true) return [kayan, undefined] as const
|
|
30
|
+
const asked = operations.findVersions === true
|
|
31
|
+
? client({ kayan, operation: 'find', input: { limit: 1, depth: 0, ...(where && { where }) }, draft: true })
|
|
32
|
+
: client({ kayan, operation: 'count', input: where ? { where } : {} })
|
|
33
|
+
return [kayan, await (asked as Promise<{ totalDocs: number }>).then((found) => found.totalDocs).catch(() => undefined)] as const
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export function Home({ loaderData: counts }: PageProps<Record<string, number | undefined>>) {
|
|
39
|
+
const { locale, t } = useLocale()
|
|
40
|
+
const user = useUser()
|
|
41
|
+
const screen = useScreen()
|
|
42
|
+
// One sentence per locale, so the Arabic greeting takes the Arabic comma.
|
|
43
|
+
const welcome = t('portal.welcome', 'Welcome, {name}')!.replace('{name}', () => user.name)
|
|
44
|
+
return (
|
|
45
|
+
<PortalTopShell
|
|
46
|
+
regions={{
|
|
47
|
+
header: <h1 title={welcome} className="truncate">{welcome}</h1>,
|
|
48
|
+
nav: <ScreenNav />,
|
|
49
|
+
main: (
|
|
50
|
+
<section data-dashboard="portal" className="@container grid gap-4">
|
|
51
|
+
<p className="text-muted-foreground">{t('portal.about', 'What you may open here, and how many of each you may read.')}</p>
|
|
52
|
+
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2">
|
|
53
|
+
{figures.map(([kayan, icon]) => {
|
|
54
|
+
const entry = screen.nav.find((one) => one.kayan === kayan)
|
|
55
|
+
const counted = counts?.[kayan]
|
|
56
|
+
return entry ? (
|
|
57
|
+
<KpiCard
|
|
58
|
+
key={kayan}
|
|
59
|
+
kpi={kayan}
|
|
60
|
+
icon={icon}
|
|
61
|
+
title={navLabel({ entry, screen, locale, t })}
|
|
62
|
+
value={counted === undefined ? '—' : new Intl.NumberFormat(locale).format(counted)}
|
|
63
|
+
footer={<a href={`${screen.path}${entry.path}`} className="text-foreground inline-flex items-center hover:underline max-sm:min-h-11">{t('portal.open', 'Open the list')}</a>}
|
|
64
|
+
/>
|
|
65
|
+
) : null
|
|
66
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
),
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The portal's one frame: a single column, `main`, drawn as a div because the
|
|
2
|
+
// shell around it already holds the page's one main landmark. Its one track may
|
|
3
|
+
// shrink below its widest child, so nothing wide stretches the portal past a phone.
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import type { LayoutProps } from 'mithaq/admin'
|
|
6
|
+
|
|
7
|
+
export const regions = ['main'] as const
|
|
8
|
+
|
|
9
|
+
export const Page = ({ regions, page }: LayoutProps<readonly ['main'], ReactNode>) => (
|
|
10
|
+
<div data-layout="page" data-page={page.path} className="grid grid-cols-[minmax(0,1fr)] gap-4">{regions.main}</div>
|
|
11
|
+
)
|