create-mithaq 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +56 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/copy.ts +870 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +159 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// What each screen draws of the generated `user` kayan, which `mithaq generate
|
|
2
|
+
// auth` imports into `./user.ts` because this file sits beside it, spreading it
|
|
3
|
+
// into the kayan's `targets` after the generated titles of the screens it does
|
|
4
|
+
// not name, so the platform's block here is that screen's whole block, its
|
|
5
|
+
// title included, and the dashboard keeps the generated one. The dashboard
|
|
6
|
+
// titles a person by name for its cells and pickers, and the platform lists
|
|
7
|
+
// every person with the registry's list and acts on each from its row's menu
|
|
8
|
+
// and its own page.
|
|
9
|
+
import { platformUi } from '../../../mithaq.ts'
|
|
10
|
+
import { appPage } from '../../../pages.ts'
|
|
11
|
+
|
|
12
|
+
export const screens = {
|
|
13
|
+
platform: {
|
|
14
|
+
useAsTitle: 'name',
|
|
15
|
+
pages: {
|
|
16
|
+
list: {
|
|
17
|
+
path: '/users',
|
|
18
|
+
page: appPage([{ layout: 'table', main: [{ list: {
|
|
19
|
+
columns: ['name', 'email', { field: 'role', cell: platformUi('user-actions.tsx#PlatformRole') }, 'banned', 'createdAt'],
|
|
20
|
+
filters: ['name', 'email', 'role', 'banned', 'createdAt'],
|
|
21
|
+
search: ['name', 'email'],
|
|
22
|
+
sort: 'email',
|
|
23
|
+
// Set role, ban and impersonate, after the row menu's View.
|
|
24
|
+
menu: platformUi('user-actions.tsx#UserMenu'),
|
|
25
|
+
} }] }]),
|
|
26
|
+
},
|
|
27
|
+
// A person's record, which a row opens and which only reads: every
|
|
28
|
+
// column drawn by its cell, under the actions the row's menu offers, as buttons.
|
|
29
|
+
view: {
|
|
30
|
+
path: '/users/:id',
|
|
31
|
+
page: appPage([{
|
|
32
|
+
layout: 'record',
|
|
33
|
+
main: [
|
|
34
|
+
{ component: platformUi('user-actions.tsx#UserButtons') },
|
|
35
|
+
{ section: 'person', label: { en: 'Person', ar: 'الشخص' }, fields: ['name', 'email', 'emailVerified', 'image'] },
|
|
36
|
+
// The role by its label, where the column holds the slugs.
|
|
37
|
+
{ section: 'standing', label: { en: 'Standing on the platform', ar: 'الوضع في المنصة' }, fields: [{ field: 'role', cell: platformUi('user-actions.tsx#PlatformRole') }, 'banned', 'banReason', 'banExpires'] },
|
|
38
|
+
],
|
|
39
|
+
// No id: a person reads the record by its name, and the address bar holds the id.
|
|
40
|
+
side: [{ section: 'record', label: { en: 'Record', ar: 'السجل' }, fields: ['createdAt', 'updatedAt'] }],
|
|
41
|
+
}]),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} as const
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Written by `mithaq generate auth` from the Better Auth options generate.providers names. Do not edit.
|
|
2
|
+
// This kayan's access and what each screen draws of it are the project's:
|
|
3
|
+
// `user.access.ts` exporting `access` and `user.screens.ts` exporting
|
|
4
|
+
// `screens`, beside this file, are imported here on the next run. A kayan
|
|
5
|
+
// built under this one's name elsewhere is refused by every relationship to it.
|
|
6
|
+
import { betterAuth, defaultFields, defaultOperations } from 'mithaq'
|
|
7
|
+
import { defineKayan } from '../../../mithaq.ts'
|
|
8
|
+
import { providers } from '@asas/auth'
|
|
9
|
+
import { access } from './user.access.ts'
|
|
10
|
+
import { screens } from './user.screens.ts'
|
|
11
|
+
|
|
12
|
+
export const user = defineKayan({
|
|
13
|
+
name: 'user',
|
|
14
|
+
auth: betterAuth({ server: providers.user.server }),
|
|
15
|
+
targets: { dashboard: { useAsTitle: 'name' }, portal: { useAsTitle: 'name' }, ...screens, service: { operations: defaultOperations } },
|
|
16
|
+
access,
|
|
17
|
+
fields: {
|
|
18
|
+
...defaultFields,
|
|
19
|
+
id: { kind: 'text' },
|
|
20
|
+
name: { kind: 'text', rules: { required: true } },
|
|
21
|
+
email: { kind: 'text', rules: { required: true, unique: true } },
|
|
22
|
+
emailVerified: { kind: 'checkbox', rules: { required: true } },
|
|
23
|
+
image: { kind: 'text' },
|
|
24
|
+
createdAt: { kind: 'datetime', rules: { required: true } },
|
|
25
|
+
updatedAt: { kind: 'datetime', rules: { required: true, nowOnUpdate: true, providedBy: { update: 'application' } } },
|
|
26
|
+
role: { kind: 'text' },
|
|
27
|
+
banned: { kind: 'checkbox' },
|
|
28
|
+
banReason: { kind: 'text' },
|
|
29
|
+
banExpires: { kind: 'datetime' },
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// This project's access for the generated `verification` kayan, which `mithaq generate
|
|
2
|
+
// auth` imports into `./verification.ts` because this file sits beside it.
|
|
3
|
+
//
|
|
4
|
+
// Better Auth alone writes and reads this table, through its own adapter, so
|
|
5
|
+
// every managed operation on it is refused to every actor. Written out rather
|
|
6
|
+
// than left to the administrator's default, which a super admin passes in the
|
|
7
|
+
// platform app.
|
|
8
|
+
export { providerOnly as access } from '../../../tenant-access.ts'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Written by `mithaq generate auth` from the Better Auth options generate.providers names. Do not edit.
|
|
2
|
+
// This kayan's access and what each screen draws of it are the project's:
|
|
3
|
+
// `verification.access.ts` exporting `access` and `verification.screens.ts` exporting
|
|
4
|
+
// `screens`, beside this file, are imported here on the next run. A kayan
|
|
5
|
+
// built under this one's name elsewhere is refused by every relationship to it.
|
|
6
|
+
import { defaultFields, defaultOperations } from 'mithaq'
|
|
7
|
+
import { defineKayan } from '../../../mithaq.ts'
|
|
8
|
+
import { access } from './verification.access.ts'
|
|
9
|
+
|
|
10
|
+
export const verification = defineKayan({
|
|
11
|
+
name: 'verification',
|
|
12
|
+
targets: { service: { operations: defaultOperations } },
|
|
13
|
+
access,
|
|
14
|
+
fields: {
|
|
15
|
+
...defaultFields,
|
|
16
|
+
id: { kind: 'text' },
|
|
17
|
+
identifier: { kind: 'text', rules: { required: true } },
|
|
18
|
+
value: { kind: 'text', rules: { required: true } },
|
|
19
|
+
expiresAt: { kind: 'datetime', rules: { required: true } },
|
|
20
|
+
createdAt: { kind: 'datetime', rules: { required: true } },
|
|
21
|
+
updatedAt: { kind: 'datetime', rules: { required: true, nowOnUpdate: true, providedBy: { update: 'application' } } },
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultFields,
|
|
3
|
+
defaultLockTime,
|
|
4
|
+
defaultMaxLoginAttempts,
|
|
5
|
+
defaultTokenExpiration,
|
|
6
|
+
local,
|
|
7
|
+
localAuthFields,
|
|
8
|
+
localAuthOperations,
|
|
9
|
+
defaultOperations,
|
|
10
|
+
type Ctx,
|
|
11
|
+
} from 'mithaq'
|
|
12
|
+
import { defineKayan, requestContext } from '../mithaq.ts'
|
|
13
|
+
import { appPage, platformList } from '../pages.ts'
|
|
14
|
+
import { superAdmin } from '../tenant-access.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The trusted-header names this deployment's own proxy overwrites with the real client address, read
|
|
18
|
+
* from the same `TRUSTED_IP_HEADER` variable Better Auth's `advanced.ipAddress.ipAddressHeaders` is
|
|
19
|
+
* fed from, so both limiters key on the same address. Unset, it falls back to `x-forwarded-for`.
|
|
20
|
+
*/
|
|
21
|
+
export const ip = (request: Request): string | null => {
|
|
22
|
+
const headers = process.env.TRUSTED_IP_HEADER?.split(',').map((header) => header.trim()).filter(Boolean) ?? [
|
|
23
|
+
'x-forwarded-for',
|
|
24
|
+
]
|
|
25
|
+
for (const header of headers) {
|
|
26
|
+
const value = request.headers.get(header)?.split(',').at(-1)?.trim()
|
|
27
|
+
if (value) return value
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The secret local auth signs its tokens with, 32 characters at least, from the environment and
|
|
34
|
+
* never from this file: a secret written here is one anyone reading the repository forges a token
|
|
35
|
+
* with. Unset or empty, local auth is off as a whole: every POST to `/api/auth/local` answers
|
|
36
|
+
* `withoutSecret` below with a 503, and the public sign-up with a 403. The kayan keeps its provider,
|
|
37
|
+
* fields and operations, so the build and every file generated from it are the same in every
|
|
38
|
+
* environment.
|
|
39
|
+
*/
|
|
40
|
+
const secret = process.env.LOCAL_AUTH_SECRET || undefined
|
|
41
|
+
|
|
42
|
+
const markVerified = (ctx: Ctx): Ctx => ({ ...ctx, data: { ...ctx.data, _verified: true } })
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The kayan the local auth provider writes. It spreads the shipped `localAuthFields` and
|
|
46
|
+
* `localAuthOperations`, so every column and step it has is written here, and the build refuses a
|
|
47
|
+
* protected column left readable.
|
|
48
|
+
*/
|
|
49
|
+
export const localUsers = defineKayan({
|
|
50
|
+
name: 'localUsers',
|
|
51
|
+
dbName: 'local_users',
|
|
52
|
+
messages: {
|
|
53
|
+
singular: { en: 'Local account', ar: 'حساب محلي' },
|
|
54
|
+
plural: { en: 'Local accounts', ar: 'الحسابات المحلية' },
|
|
55
|
+
},
|
|
56
|
+
// `context` is what first registration hands its steps.
|
|
57
|
+
//
|
|
58
|
+
// `ip` reads the last entry of whichever header `TRUSTED_IP_HEADER` names, the one the trusted proxy
|
|
59
|
+
// in front of it appended, because no host this template is served by hands the socket address to
|
|
60
|
+
// the `Request`. That is only safe behind exactly one such proxy: without one any caller writes the
|
|
61
|
+
// header, and behind more than one the last entry is the nearest proxy's own address, so every user
|
|
62
|
+
// would share one count; there, take the entry at the depth of the trusted proxies. A project served
|
|
63
|
+
// bare passes its server's own address instead, such as Bun's `server.requestIP(request)`. No header
|
|
64
|
+
// answers null, which leaves the request uncounted.
|
|
65
|
+
// No `email` is passed, so no mail is sent: a super admin verifies each sign-up in the platform app.
|
|
66
|
+
auth: local({
|
|
67
|
+
secret,
|
|
68
|
+
withoutSecret: 'Local sign-in is off on this server: LOCAL_AUTH_SECRET is not set. Set it to a secret of 32 characters at least and restart the server.',
|
|
69
|
+
context: requestContext,
|
|
70
|
+
ip,
|
|
71
|
+
tokenExpiration: defaultTokenExpiration,
|
|
72
|
+
maxLoginAttempts: defaultMaxLoginAttempts,
|
|
73
|
+
lockTime: defaultLockTime,
|
|
74
|
+
}),
|
|
75
|
+
targets: {
|
|
76
|
+
service: {
|
|
77
|
+
operations: {
|
|
78
|
+
...defaultOperations,
|
|
79
|
+
...localAuthOperations,
|
|
80
|
+
// The provider's own update with one step before it that writes `_verified: true` over whatever
|
|
81
|
+
// the call sent, so it can only verify; the access below judges it as an update with that one key
|
|
82
|
+
// as its data.
|
|
83
|
+
verify: { ...localAuthOperations.update, steps: [markVerified, ...localAuthOperations.update.steps] },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* Where a super admin verifies a sign-up: the accounts by email, verified state and the date each
|
|
88
|
+
* was made, and an edit page whose form is the verified toggle alone. Its fields are written here,
|
|
89
|
+
* never read off `/api/access`, which reports `update` for `email` because the kayan's own `update`
|
|
90
|
+
* asks the data and the report has none; the access above still refuses any write but `_verified`.
|
|
91
|
+
* The form sends only what changed, so an unchanged toggle writes nothing and leaves `updatedAt`
|
|
92
|
+
* alone. The protected columns are named by no page, so no list offers one as a column or a filter.
|
|
93
|
+
*/
|
|
94
|
+
platform: {
|
|
95
|
+
useAsTitle: 'email',
|
|
96
|
+
pages: {
|
|
97
|
+
...platformList('/local-users', {
|
|
98
|
+
columns: ['email', '_verified', 'createdAt'],
|
|
99
|
+
filters: ['_verified'],
|
|
100
|
+
search: ['email'],
|
|
101
|
+
row: [{
|
|
102
|
+
button: 'verify',
|
|
103
|
+
when: { or: [{ 'record._verified': { exists: false } }, { 'record._verified': { equals: false } }] },
|
|
104
|
+
label: { en: 'Verify', ar: 'توثيق' },
|
|
105
|
+
confirm: {
|
|
106
|
+
title: { en: 'Verify this account?', ar: 'توثيق هذا الحساب؟' },
|
|
107
|
+
body: { en: 'Its owner can sign in from now on.', ar: 'يستطيع صاحبه تسجيل الدخول من الآن.' },
|
|
108
|
+
},
|
|
109
|
+
}],
|
|
110
|
+
}),
|
|
111
|
+
// Read-only above the toggle: a read-only leaf is drawn and never sent, so a save still writes
|
|
112
|
+
// `_verified` alone.
|
|
113
|
+
edit: {
|
|
114
|
+
path: '/local-users/:id',
|
|
115
|
+
page: appPage([{ layout: 'record', main: [{ form: [{ section: 'verification', label: { en: 'Verification', ar: 'التحقق' }, fields: [
|
|
116
|
+
{ field: 'email', readOnly: true },
|
|
117
|
+
{ field: 'createdAt', readOnly: true },
|
|
118
|
+
'_verified',
|
|
119
|
+
] }] }] }]),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
// Public sign-up stays open. A super admin, in the platform app alone, verifies an account by writing
|
|
125
|
+
// `_verified: true`, which the field's own write admits from the administrator and nobody else. The
|
|
126
|
+
// super admin writes `_verified` and nothing else: a password or an email it set would be an account
|
|
127
|
+
// it could then log in as, and no audit entry records a write to a kayan with no tenant. An
|
|
128
|
+
// `updateMany` is asked this again for each row it writes, with that row's values. Everyone else, a
|
|
129
|
+
// local user on its own row included, reads and writes none.
|
|
130
|
+
access: {
|
|
131
|
+
create: () => true,
|
|
132
|
+
read: superAdmin,
|
|
133
|
+
update: ({ user, data }) => superAdmin({ user }) && Object.keys(data ?? {}).every((key) => key === '_verified'),
|
|
134
|
+
delete: () => false,
|
|
135
|
+
},
|
|
136
|
+
// A label leaves the shipped access in place, which the build compares by identity.
|
|
137
|
+
fields: {
|
|
138
|
+
...defaultFields,
|
|
139
|
+
...localAuthFields,
|
|
140
|
+
email: { ...localAuthFields.email, label: { en: 'Email', ar: 'البريد الإلكتروني' } },
|
|
141
|
+
_verified: { ...localAuthFields._verified, label: { en: 'Verified', ar: 'موثق' } },
|
|
142
|
+
},
|
|
143
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { actorPermissions, defaultFields, defaultOperations, folderOf, uploadFields, withUploadReference, type Where } from 'mithaq'
|
|
2
|
+
import { defineKayan, serverTargets, organization } from '../mithaq.ts'
|
|
3
|
+
import { courses } from '../courses/courses.ts'
|
|
4
|
+
import { currentOrganization, readsPast } from '../tenant-access.ts'
|
|
5
|
+
import { appPage, danger } from '../pages.ts'
|
|
6
|
+
import { storageConfig } from '../storage/index.ts'
|
|
7
|
+
|
|
8
|
+
const ui = folderOf(new URL('./ui/', import.meta.url))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The images an actor reads, inside its active organization: every image of
|
|
12
|
+
* its organization where it holds `media.create`, and otherwise only the cover
|
|
13
|
+
* of a course it reads. `media.create` is the line
|
|
14
|
+
* because a role that adds images has to find one before any course names it,
|
|
15
|
+
* in the cover's picker. So a student or an instructor reads no image of a
|
|
16
|
+
* draft, of an archived course or of none, through find, findByID and
|
|
17
|
+
* `GET /api/media/file/<id>`, and the administrator and the registrar read all of them.
|
|
18
|
+
*
|
|
19
|
+
* "A course it reads" is a published course of the organization the actor's session has
|
|
20
|
+
* active, read off the trusted snapshot, under the courses kayan's own read
|
|
21
|
+
* access for this actor. The courses are read straight through the store,
|
|
22
|
+
* past the runner's scope, so the organization is named here and the read
|
|
23
|
+
* never reaches past it whatever the access answers.
|
|
24
|
+
* An image has no field that points at a course, so a row filter cannot reach
|
|
25
|
+
* one, and the covers are read first and named by id.
|
|
26
|
+
*/
|
|
27
|
+
const coversOfReadableCourses = async ({ user }: { user: unknown }) => {
|
|
28
|
+
if (readsPast(user, 'media.create')) return true
|
|
29
|
+
const held = actorPermissions(user)
|
|
30
|
+
if (held !== true && !held?.has('courses.find')) return false
|
|
31
|
+
const active = currentOrganization(user)
|
|
32
|
+
const readable = active && (await courses.access!.read!({ user }))
|
|
33
|
+
if (!readable) return false
|
|
34
|
+
const published: Where = { and: [{ organization: { equals: active } }, { _status: { equals: 'published' } }] }
|
|
35
|
+
const found = await serverTargets.drizzle.find({ kayan: 'courses', args: {
|
|
36
|
+
limit: 0,
|
|
37
|
+
page: 1,
|
|
38
|
+
sort: { field: 'id', direction: 'asc' },
|
|
39
|
+
where: readable === true ? published : { and: [readable, published] },
|
|
40
|
+
} })
|
|
41
|
+
const ids = [...new Set(found.docs.map((row) => row.cover).filter((id): id is string => typeof id === 'string'))]
|
|
42
|
+
return ids.length ? { id: { in: ids } } : false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The organization's images: a row per file, whose object is kept where the
|
|
47
|
+
* project's storage puts it. A course points at one as its cover.
|
|
48
|
+
*
|
|
49
|
+
* It spreads `uploadFields`, the columns the create writes from the upload it
|
|
50
|
+
* verified and nobody writes after, and declares its own create and delete:
|
|
51
|
+
* the create takes the signed `uploadReference` that
|
|
52
|
+
* `POST /api/media/uploadInstructions` handed out and runs the project's
|
|
53
|
+
* `uploaded`, and the delete runs its `removed` after the commit. `buildAsas`
|
|
54
|
+
* refuses a create that skips the configured `uploaded`. A file is
|
|
55
|
+
* read at `GET /api/media/file/<id>` through this kayan's own `findByID`, so
|
|
56
|
+
* its organization and `coversOfReadableCourses` decide who reads it. It keeps no versions, since a file never changes once it is
|
|
57
|
+
* made; a course keeps the versions of its cover instead.
|
|
58
|
+
*/
|
|
59
|
+
export const media = defineKayan({
|
|
60
|
+
name: 'media',
|
|
61
|
+
// ANDed with the inherited rows, so it narrows and never widens.
|
|
62
|
+
access: { read: coversOfReadableCourses },
|
|
63
|
+
messages: {
|
|
64
|
+
singular: { en: 'Image', ar: 'صورة' },
|
|
65
|
+
plural: { en: 'Images', ar: 'الصور' },
|
|
66
|
+
},
|
|
67
|
+
targets: {
|
|
68
|
+
storage: { mimeTypes: ['image/png', 'image/jpeg', 'image/webp'] },
|
|
69
|
+
// A file is named by its name wherever it stands for itself: the upload
|
|
70
|
+
// control's picker and a relationship column pointing here.
|
|
71
|
+
portal: { useAsTitle: 'filename' },
|
|
72
|
+
// The organization's images, listed and re-described. A file is made
|
|
73
|
+
// where it is used, by the upload control, and on this kayan's own New
|
|
74
|
+
// page, both through the upload dialog, which sends it before the row is
|
|
75
|
+
// made. The list draws each image's thumbnail beside its name and its size as a
|
|
76
|
+
// person reads it. The file's own columns are the runner's, so the edit
|
|
77
|
+
// page draws them read-only, with the image, its size, Download and
|
|
78
|
+
// Replace file beside the form, and above it on a phone.
|
|
79
|
+
dashboard: {
|
|
80
|
+
useAsTitle: 'filename',
|
|
81
|
+
description: { en: 'The images this organization uses, each with the words a screen reader says for it.', ar: 'الصور التي تستخدمها هذه المؤسسة، ولكل منها الكلمات التي يقرؤها قارئ الشاشة.' },
|
|
82
|
+
pages: {
|
|
83
|
+
list: { path: '/media', page: appPage([{ layout: 'table', main: [{ list: {
|
|
84
|
+
columns: [{ field: 'filename', cell: ui('preview.tsx#FileName') }, 'alt', 'mimeType', { field: 'filesize', cell: ui('preview.tsx#FileSize') }, 'createdAt'],
|
|
85
|
+
search: ['filename'],
|
|
86
|
+
sort: '-createdAt',
|
|
87
|
+
} }] }]) },
|
|
88
|
+
create: { path: '/media/new', component: ui('preview.tsx#NewImage') },
|
|
89
|
+
edit: {
|
|
90
|
+
path: '/media/:id',
|
|
91
|
+
page: appPage([{
|
|
92
|
+
layout: 'record',
|
|
93
|
+
main: [{ form: [
|
|
94
|
+
{ section: 'description', label: { en: 'Description', ar: 'الوصف' }, fields: ['alt'] },
|
|
95
|
+
{ section: 'file', label: { en: 'File', ar: 'الملف' }, fields: ['filename', 'mimeType', { row: ['width', 'height'] }] },
|
|
96
|
+
] }],
|
|
97
|
+
side: [{ component: ui('preview.tsx#MediaPreview') }],
|
|
98
|
+
danger: danger(),
|
|
99
|
+
}]),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
service: {
|
|
104
|
+
operations: {
|
|
105
|
+
...defaultOperations,
|
|
106
|
+
// The upload inside the create. Before the transaction opens, the
|
|
107
|
+
// signed reference is checked and the stored object is read back,
|
|
108
|
+
// and every file column is written from what the server measured.
|
|
109
|
+
// Then the row is made in one insert.
|
|
110
|
+
create: {
|
|
111
|
+
...defaultOperations.create,
|
|
112
|
+
input: ({ create }) => withUploadReference(create),
|
|
113
|
+
beforeTransaction: [storageConfig.uploaded],
|
|
114
|
+
},
|
|
115
|
+
// The object is removed only once the row's removal has committed,
|
|
116
|
+
// so a failure leaves an orphan for the sweep and never a row that
|
|
117
|
+
// names nothing.
|
|
118
|
+
delete: {
|
|
119
|
+
...defaultOperations.delete,
|
|
120
|
+
afterCommit: ({ afterCommit }) => [...afterCommit, storageConfig.removed],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
fields: {
|
|
126
|
+
...defaultFields,
|
|
127
|
+
organization,
|
|
128
|
+
...uploadFields,
|
|
129
|
+
// What a screen reader says for the image, in every locale, since an
|
|
130
|
+
// image with no words is no image to someone who cannot see it.
|
|
131
|
+
alt: {
|
|
132
|
+
kind: 'text',
|
|
133
|
+
label: { en: 'Alternative text', ar: 'النص البديل' },
|
|
134
|
+
rules: { localized: true, required: true, maxLength: 300 },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// What the `media` kayan draws of its own files: the image beside its edit
|
|
2
|
+
// form, the list's thumbnail and size cells, and the page a new image is made
|
|
3
|
+
// on. Each reads the row's computed `url` and `thumbnailURL`, so a file is
|
|
4
|
+
// fetched through the file route and the row's access decides. A new file is
|
|
5
|
+
// made in the registry's upload dialog, which sends the bytes before the row
|
|
6
|
+
// is made.
|
|
7
|
+
import { useRef, useState } from 'react'
|
|
8
|
+
import { Download, Upload } from 'lucide-react'
|
|
9
|
+
import type { CellProps, ComponentProps, Row } from 'mithaq/admin'
|
|
10
|
+
import { useAccess, useClient, useLocale, useRecord, useScreen } from 'mithaq/admin/react'
|
|
11
|
+
import { locales } from '@/copy.ts'
|
|
12
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
13
|
+
import { FileThumb, FileValue, UploadDialog, sizeOf } from '@/components/mithaq/upload.tsx'
|
|
14
|
+
import { AppShell } from '../../ui/app-shell.tsx'
|
|
15
|
+
|
|
16
|
+
/** The `filesize` column: the size as a person reads it, never the raw bytes, in the upload dialog's own units. */
|
|
17
|
+
export function FileSize({ value }: CellProps) {
|
|
18
|
+
const { locale } = useLocale()
|
|
19
|
+
return typeof value === 'number' ? <span className="whitespace-nowrap">{sizeOf(value, locale)}</span> : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `filename` column: the image's thumbnail before its name. The name
|
|
24
|
+
* keeps at least seven rem and wraps onto a second line before it is cut,
|
|
25
|
+
* between words where it has them and anywhere only where it must, so
|
|
26
|
+
* on a phone the table gives the column room for it rather than squeezing it
|
|
27
|
+
* to "co…" beside the thumbnail.
|
|
28
|
+
*/
|
|
29
|
+
export function FileName({ value, row }: CellProps) {
|
|
30
|
+
const { t, locale } = useLocale()
|
|
31
|
+
const name = String(value ?? '')
|
|
32
|
+
return (
|
|
33
|
+
<span className="inline-flex min-w-0 items-center gap-2">
|
|
34
|
+
<FileThumb row={row} t={t} locale={locale} size="xs" />
|
|
35
|
+
<span className="line-clamp-2 min-w-28 [overflow-wrap:anywhere]" dir="auto" title={name}>{name}</span>
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const useEditHref = () => {
|
|
41
|
+
const { path, kayan } = useScreen()
|
|
42
|
+
return (id: unknown) => `${path}${kayan.media?.pages.edit?.path.replace(':id', encodeURIComponent(String(id)))}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The upload dialog over this kayan, which opens the new image's page once it is made. */
|
|
46
|
+
function NewImageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
47
|
+
const { t, locale } = useLocale()
|
|
48
|
+
const { kayan } = useScreen()
|
|
49
|
+
const client = useClient()
|
|
50
|
+
const editHref = useEditHref()
|
|
51
|
+
// The dialog closes itself once the row is made, and that close is no
|
|
52
|
+
// Cancel: the new image's page is already opening.
|
|
53
|
+
const done = useRef(false)
|
|
54
|
+
return kayan.media ? (
|
|
55
|
+
<UploadDialog
|
|
56
|
+
open={open}
|
|
57
|
+
onOpenChange={(next) => done.current || onOpenChange(next)}
|
|
58
|
+
target={kayan.media}
|
|
59
|
+
client={client}
|
|
60
|
+
t={t}
|
|
61
|
+
locale={locale}
|
|
62
|
+
locales={locales}
|
|
63
|
+
onDone={(row: Row) => {
|
|
64
|
+
done.current = true
|
|
65
|
+
window.location.assign(editHref(row.id))
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
) : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The image beside its edit form, and above it on a phone, where the record
|
|
73
|
+
* layout puts a side marked `data-lead` first: the image itself, its size, a
|
|
74
|
+
* link that downloads it, and Replace file. A file never changes once it is
|
|
75
|
+
* made, so Replace file uploads a new image and opens it, and this one stays,
|
|
76
|
+
* with whatever points at it, until it is deleted.
|
|
77
|
+
*/
|
|
78
|
+
export function MediaPreview() {
|
|
79
|
+
const { t, locale } = useLocale()
|
|
80
|
+
const access = useAccess()
|
|
81
|
+
const row = useRecord()
|
|
82
|
+
const [replacing, setReplacing] = useState(false)
|
|
83
|
+
if (!row) return null
|
|
84
|
+
const creates = access.collections?.media?.create === true
|
|
85
|
+
const url = typeof row.url === 'string' ? row.url : undefined
|
|
86
|
+
return (
|
|
87
|
+
<div data-media-preview="" data-lead="" className="grid justify-items-start gap-3">
|
|
88
|
+
<FileValue row={row} t={t} locale={locale} />
|
|
89
|
+
{typeof row.filesize === 'number' ? <p className="text-muted-foreground text-sm">{sizeOf(row.filesize, locale)}</p> : null}
|
|
90
|
+
<div className="flex flex-wrap gap-2">
|
|
91
|
+
{url ? (
|
|
92
|
+
<a href={url} download={typeof row.filename === 'string' ? row.filename : ''} data-media-download="" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
|
|
93
|
+
<Download className="size-4" aria-hidden />
|
|
94
|
+
{t('media.download', 'Download')}
|
|
95
|
+
</a>
|
|
96
|
+
) : null}
|
|
97
|
+
{creates ? (
|
|
98
|
+
<Button type="button" variant="outline" size="sm" data-media-replace="" onClick={() => setReplacing(true)}>
|
|
99
|
+
<Upload className="size-4" aria-hidden />
|
|
100
|
+
{t('media.replace', 'Replace file')}
|
|
101
|
+
</Button>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
{creates ? <p className="text-muted-foreground text-xs">{t('media.replaceAbout', 'Replace file uploads a new image and opens it. This one stays until you delete it.')}</p> : null}
|
|
105
|
+
<NewImageDialog open={replacing} onOpenChange={setReplacing} />
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The New page, `/media/new`, which the list's New button opens: the upload
|
|
112
|
+
* dialog, open at once, and the page under it, which opens it again. Closing
|
|
113
|
+
* it goes back to the list.
|
|
114
|
+
*/
|
|
115
|
+
export function NewImage(_props: ComponentProps) {
|
|
116
|
+
const { t } = useLocale()
|
|
117
|
+
const { path, kayan } = useScreen()
|
|
118
|
+
const [open, setOpen] = useState(true)
|
|
119
|
+
const list = `${path}${kayan.media?.pages.list?.path ?? ''}`
|
|
120
|
+
return (
|
|
121
|
+
<AppShell
|
|
122
|
+
regions={{
|
|
123
|
+
header: <h1>{t('media.new', 'New image')}</h1>,
|
|
124
|
+
main: (
|
|
125
|
+
<div data-media-new="" className="grid justify-items-start gap-4">
|
|
126
|
+
<p className="text-muted-foreground">{t('media.newAbout', 'Choose an image and describe it. It is uploaded before it is saved.')}</p>
|
|
127
|
+
<Button type="button" onClick={() => setOpen(true)}>
|
|
128
|
+
<Upload className="size-4" aria-hidden />
|
|
129
|
+
{t('admin.upload.new', 'Upload new')}
|
|
130
|
+
</Button>
|
|
131
|
+
<NewImageDialog
|
|
132
|
+
open={open}
|
|
133
|
+
onOpenChange={(next) => {
|
|
134
|
+
setOpen(next)
|
|
135
|
+
if (!next) window.location.assign(list)
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
),
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|