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.
Files changed (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. package/README.md +0 -3
@@ -0,0 +1,87 @@
1
+ // What the portal's pages decide, apart from what they draw, so a test reads it under plain `node`.
2
+ import { holds, type AccessReport, type Row, type Translate } from 'mithaq/admin'
3
+
4
+ /** Which part of the day a greeting names, by the hour on the viewer's own clock. */
5
+ export const partOfDay = (hour: number) => (hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening')
6
+
7
+ const timeOf = (value: unknown) => (value instanceof Date || typeof value === 'string' ? new Date(value).getTime() : Number.NaN)
8
+
9
+ /** The first session of each course still to start, by the course's id. An archived session takes no seat, so it is never next. */
10
+ export const nextByCourse = (sessions: readonly Row[], now: number): Map<string, Row> => {
11
+ const next = new Map<string, Row>()
12
+ for (const one of sessions) {
13
+ const at = timeOf(one.startsAt)
14
+ if (one.archived === true || !(at > now) || typeof one.course !== 'string') continue
15
+ const held = next.get(one.course)
16
+ if (!held || at < timeOf(held.startsAt)) next.set(one.course, one)
17
+ }
18
+ return next
19
+ }
20
+
21
+ /**
22
+ * Seats split by when their session meets: those to come, soonest first, and those past, latest first. A seat
23
+ * whose session the viewer cannot read has no start and is in neither.
24
+ */
25
+ export const byWhen = <Seat extends { startsAt?: unknown }>(seats: readonly Seat[], now: number) => {
26
+ const timed = seats.filter((one) => !Number.isNaN(timeOf(one.startsAt)))
27
+ const upcoming = timed.filter((one) => timeOf(one.startsAt) > now).sort((a, b) => timeOf(a.startsAt) - timeOf(b.startsAt))
28
+ const past = timed.filter((one) => timeOf(one.startsAt) <= now).sort((a, b) => timeOf(b.startsAt) - timeOf(a.startsAt))
29
+ return { upcoming, past }
30
+ }
31
+
32
+ /** A fee in its own currency, a fee of nought as free, and nothing for a course that names none. */
33
+ export const feeOf = ({ fee, currency, locale, t }: { fee: unknown; currency: unknown; locale: string; t: Translate }): string | undefined => {
34
+ if (typeof fee !== 'number') return undefined
35
+ if (fee === 0) return t('portal.free', 'Free')
36
+ try {
37
+ if (typeof currency === 'string' && currency) return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(fee)
38
+ } catch {
39
+ // A code Intl refuses throws a RangeError; the number alone is still true.
40
+ }
41
+ return fee.toLocaleString(locale)
42
+ }
43
+
44
+ /** What a session says of its seats: nothing where it holds any number, Full, or how many are left. */
45
+ export const seatsOf = ({ seatsLeft, locale, t }: { seatsLeft: unknown; locale: string; t: Translate }): string | undefined => {
46
+ if (typeof seatsLeft !== 'number') return undefined
47
+ if (seatsLeft <= 0) return t('portal.full', 'Full')
48
+ // The plural form the locale takes for the count, as the admin's `counted` reads it, which lives in a `.tsx` file.
49
+ const form = t(`portal.seatsLeft_${new Intl.PluralRules(locale).select(seatsLeft)}`) ?? t('portal.seatsLeft_other', '{count} seats left')!
50
+ return form.replace('{count}', () => seatsLeft.toLocaleString(locale))
51
+ }
52
+
53
+ const hour = 60 * 60 * 1000
54
+
55
+ /** How soon a moment comes, in the viewer's words: in hours under a day, in days past one. */
56
+ export const startsIn = ({ value, now, locale }: { value: unknown; now: number; locale: string }): string | undefined => {
57
+ const at = timeOf(value)
58
+ if (Number.isNaN(at)) return undefined
59
+ const words = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
60
+ const hours = Math.round((at - now) / hour)
61
+ return Math.abs(hours) < 24 ? words.format(hours, 'hour') : words.format(Math.round(hours / 24), 'day')
62
+ }
63
+
64
+ /** A moment as a student reads it on a card: the day of the week, the date and the time, in the viewer's locale. */
65
+ export const whenOf = ({ value, locale }: { value: unknown; locale: string }): string | undefined => {
66
+ const at = timeOf(value)
67
+ if (Number.isNaN(at)) return undefined
68
+ const date = new Date(at)
69
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'short', day: 'numeric' }).format(date)
70
+ return `${day} · ${new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(date)}`
71
+ }
72
+
73
+ /**
74
+ * Whether maintenance closes the portal to this viewer. It closes it to a student alone: whoever enters the
75
+ * dashboard runs the school, and whoever reads every seat teaches, so both keep working through it.
76
+ */
77
+ export const closedTo = ({ maintenance, access }: { maintenance: unknown; access: AccessReport }) =>
78
+ maintenance === true && !holds(access, 'screen.dashboard') && !holds(access, 'enrollments.others')
79
+
80
+ /** A localized value, such as a tag's name, in the viewer's locale, else any locale it holds. */
81
+ export const inLocale = (value: unknown, locale: string): string | undefined => {
82
+ if (typeof value === 'string') return value
83
+ if (!value || typeof value !== 'object') return undefined
84
+ const held = value as Record<string, unknown>
85
+ const own = held[locale]
86
+ return typeof own === 'string' && own ? own : Object.values(held).find((one): one is string => typeof one === 'string' && !!one)
87
+ }
@@ -0,0 +1,194 @@
1
+ // The portal's frame, its own look apart from the dashboard's: the organization's name at its head, which is the
2
+ // site's name in every locale, the portal's three places as pills across the top from md up and as a tab bar at
3
+ // the foot of a phone, and one column between them. Its colors are the shadcn tokens the primitives read, set
4
+ // here to the portal's teal and amber for each theme, so a Button or a Card inside it is the portal's without a
5
+ // class of its own.
6
+ import '@fontsource-variable/bricolage-grotesque'
7
+ import { use, useState, type ReactNode } from 'react'
8
+ import { BookOpen, ClipboardCheck, House, Wrench, type LucideIcon } from 'lucide-react'
9
+ // add-on: settings
10
+ import { useQuery } from '@tanstack/react-query'
11
+ import type { Row } from 'mithaq/admin'
12
+ import { useClient } from 'mithaq/admin/react'
13
+ // end add-on: settings
14
+ import type { NavEntry, ShellProps } from 'mithaq/admin'
15
+ import { ScreenContext, useAccess, useLocale, useNav, useScreen, useUser } from 'mithaq/admin/react'
16
+ import { holds } from 'mithaq/admin'
17
+ import { currentOf } from 'mithaq-admin/nav-links.tsx'
18
+ import { DiscardDialog } from 'mithaq-admin/discard-dialog.tsx'
19
+ import { SidebarProvider } from '@/components/ui/sidebar'
20
+ import { locales, switchLocale } from '@/copy.ts'
21
+ import { frame, UserMenu } from '@asas/mithaq/ui/app-shell.tsx'
22
+ import { navLabel } from '@asas/mithaq/ui/screen-nav.tsx'
23
+ import { nameIn } from '@asas/mithaq/mithaq/auth/organizations.ts'
24
+ // add-on: settings
25
+ import { closedTo } from './rules.ts'
26
+ // end add-on: settings
27
+
28
+ export const regions = ['header', 'main'] as const
29
+
30
+ /**
31
+ * The portal's colors over shadcn's tokens, light and dark. Teal is the brand, amber its accent; each pair is
32
+ * 4.5:1 or more, text on its ground.
33
+ */
34
+ const tokens = [
35
+ '[--background:#F5F5F0] [--foreground:#1B1F24] [--card:#FFFFFF] [--card-foreground:#1B1F24]',
36
+ '[--primary:#0F6E6E] [--primary-foreground:#FFFFFF] [--ring:#0F6E6E] [--muted-foreground:#555A60]',
37
+ '[--accent:#EAF3F2] [--accent-foreground:#0F6E6E] [--border:#E4E4DC] [--input:#D9D9D0] [--portal-amber:#E8A33D]',
38
+ 'dark:[--background:#0E1414] dark:[--foreground:#ECEFEE] dark:[--card:#172020] dark:[--card-foreground:#ECEFEE]',
39
+ 'dark:[--primary:#5FB8B3] dark:[--primary-foreground:#0B2524] dark:[--ring:#5FB8B3] dark:[--muted-foreground:#A4B0AE]',
40
+ 'dark:[--accent:#16312F] dark:[--accent-foreground:#9CD9D4] dark:[--border:#26302F] dark:[--input:#34403F] dark:[--portal-amber:#E8A33D]',
41
+ ].join(' ')
42
+
43
+ /** The display face of the portal's headings. Its Latin is Bricolage Grotesque; Arabic falls to IBM Plex Sans Arabic. */
44
+ export const display = "font-['Bricolage_Grotesque_Variable','IBM_Plex_Sans_Arabic',sans-serif] font-bold tracking-tight"
45
+
46
+ const icons: Record<string, LucideIcon> = { '/': House, '/courses': BookOpen, '/enrollments': ClipboardCheck }
47
+
48
+ // add-on: settings
49
+ /** The organization's settings as the viewer reads them, or `null` where it may not or none are written yet. */
50
+ const useSettings = () => {
51
+ const client = useClient()
52
+ return useQuery({
53
+ queryKey: ['portal-settings'],
54
+ queryFn: () => (client({ kayan: 'settings', operation: 'findOne', input: {} }) as Promise<Row | null>).catch(() => null),
55
+ staleTime: 60_000,
56
+ }).data
57
+ }
58
+ // end add-on: settings
59
+
60
+ /** One place of the portal: its link, its label, and whether the viewer is on it. */
61
+ const usePlaces = () => {
62
+ const { locale, t } = useLocale()
63
+ const screen = useScreen()
64
+ const access = useAccess()
65
+ const state = use(ScreenContext)
66
+ // A seat of anyone's is no longer the viewer's own, so the list is "My enrollments" only to a student.
67
+ const label = (entry: NavEntry) =>
68
+ entry.kayan === 'enrollments' && !holds(access, 'enrollments.others') ? t('portal.myEnrollments', 'My enrollments')! : navLabel({ entry, screen, locale, t })!
69
+ return [
70
+ { path: '/', href: screen.path, label: t('portal.home', 'Home')!, current: state?.path === screen.path },
71
+ ...useNav().map((entry) => ({ path: entry.path, href: `${screen.path}${entry.path}`, label: label(entry), current: currentOf(entry, state?.kayan?.name) === 'page' })),
72
+ ]
73
+ }
74
+
75
+ /** The other language, offered as its own name, so a reader who cannot read this one still finds theirs. */
76
+ function LocaleButton() {
77
+ const { locale } = useLocale()
78
+ const other = locales.find((one) => one !== locale) ?? locale
79
+ // Asked first, in the app's discard dialog, where a form holds a change nobody saved.
80
+ const [asked, setAsked] = useState(false)
81
+ const { t } = useLocale()
82
+ return (
83
+ <>
84
+ <button
85
+ type="button"
86
+ lang={other}
87
+ aria-label={other === 'ar' ? 'العربية' : 'English'}
88
+ onClick={() => (window.onbeforeunload ? setAsked(true) : switchLocale({ to: other }))}
89
+ className="border-input bg-card text-foreground focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-full border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
90
+ >
91
+ {other === 'ar' ? 'ع' : 'EN'}
92
+ </button>
93
+ <DiscardDialog open={asked} onOpenChange={setAsked} onDiscard={() => switchLocale({ to: other })} t={t} />
94
+ </>
95
+ )
96
+ }
97
+
98
+ /** What a student meets while the portal is closed for maintenance: why, and where to write meanwhile. */
99
+ function Closed({ contact }: { contact?: string }) {
100
+ const { t } = useLocale()
101
+ return (
102
+ <section data-portal-closed="" className="bg-card mx-auto grid max-w-md justify-items-center gap-3 rounded-3xl px-6 py-12 text-center">
103
+ <Wrench aria-hidden className="text-primary size-10" />
104
+ <h1 className={`${display} text-2xl`}>{t('portal.closed', 'Closed for maintenance')}</h1>
105
+ <p className="text-muted-foreground">{t('portal.closedBody', 'The school is working on the portal. Please come back soon.')}</p>
106
+ {contact ? <Contact email={contact} /> : null}
107
+ </section>
108
+ )
109
+ }
110
+
111
+ function Contact({ email }: { email: string }) {
112
+ const { t } = useLocale()
113
+ return (
114
+ <p className="text-muted-foreground text-sm">
115
+ {t('portal.contact', 'Questions? Write to')}{' '}
116
+ <a href={`mailto:${email}`} dir="ltr" className="text-primary font-medium underline-offset-4 hover:underline">{email}</a>
117
+ </p>
118
+ )
119
+ }
120
+
121
+ export function PortalShell({ regions }: ShellProps<readonly ['header', 'main'], ReactNode>) {
122
+ const { locale, t } = useLocale()
123
+ const user = useUser()
124
+ const places = usePlaces()
125
+ // add-on: settings
126
+ const access = useAccess()
127
+ const settings = useSettings()
128
+ // The page answers what the viewer reads either way; this only draws the closed page in its place.
129
+ const closed = closedTo({ maintenance: settings?.maintenance, access })
130
+ const contact = typeof settings?.contactEmail === 'string' && settings.contactEmail ? settings.contactEmail : undefined
131
+ // end add-on: settings
132
+ // without add-on: settings
133
+ // const closed = false
134
+ // const contact: string | undefined = undefined
135
+ // end without add-on: settings
136
+ const active = user.organizations?.find((one) => one.id === user.activeOrganizationId)
137
+ const site = active ? nameIn(active, locale) : t('portal.title', 'Portal')!
138
+ const nav = t('portal.navigation', 'Portal')
139
+ return (
140
+ <SidebarProvider data-shell="top" data-portal="" style={frame} className={`bg-background text-foreground min-h-svh flex-col ${tokens}`}>
141
+ <header className="bg-card border-b">
142
+ <div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
143
+ <a href={places[0]!.href} className="flex min-w-0 items-center gap-2.5 font-semibold">
144
+ <span aria-hidden className="bg-primary text-primary-foreground grid size-9 shrink-0 place-items-center rounded-[10px]">{[...site][0]}</span>
145
+ <span className="truncate">{site}</span>
146
+ </a>
147
+ <nav aria-label={nav} className="hidden gap-1 md:flex">
148
+ {places.map((place) => (
149
+ <a
150
+ key={place.path}
151
+ href={place.href}
152
+ aria-current={place.current ? 'page' : undefined}
153
+ className="text-foreground/80 hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground inline-flex h-11 items-center rounded-full px-4 text-[15px] aria-[current=page]:font-semibold"
154
+ >
155
+ {place.label}
156
+ </a>
157
+ ))}
158
+ </nav>
159
+ <div className="flex shrink-0 items-center gap-2">
160
+ <LocaleButton />
161
+ {/* The avatar alone: the menu signs out and switches locale too. */}
162
+ <div className="w-12 [&_[data-slot=avatar]]:grayscale-0 [&_[data-slot=avatar-fallback]]:bg-(--portal-amber) [&_[data-slot=avatar-fallback]]:text-[#1B1F24]">
163
+ <UserMenu />
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </header>
168
+ <main data-region="main" className="mx-auto grid w-full max-w-6xl grid-cols-[minmax(0,1fr)] content-start gap-6 px-4 pt-6 pb-28 sm:px-6 md:pb-12">
169
+ {closed ? <Closed contact={contact} /> : <><div data-region="header">{regions.header}</div>{regions.main}</>}
170
+ </main>
171
+ {contact && !closed ? (
172
+ <footer className="mx-auto w-full max-w-6xl px-4 pb-28 sm:px-6 md:pb-8">
173
+ <Contact email={contact} />
174
+ </footer>
175
+ ) : null}
176
+ <nav aria-label={nav} className="bg-card fixed inset-x-0 bottom-0 z-10 grid grid-cols-3 border-t px-2 pt-1.5 pb-[max(env(safe-area-inset-bottom),0.75rem)] md:hidden">
177
+ {places.map((place) => {
178
+ const Icon = icons[place.path] ?? BookOpen
179
+ return (
180
+ <a
181
+ key={place.path}
182
+ href={place.href}
183
+ aria-current={place.current ? 'page' : undefined}
184
+ className="text-muted-foreground aria-[current=page]:text-primary flex min-h-13 flex-col items-center justify-center gap-1 text-xs aria-[current=page]:font-semibold"
185
+ >
186
+ <Icon aria-hidden className="size-[22px]" />
187
+ {place.label}
188
+ </a>
189
+ )
190
+ })}
191
+ </nav>
192
+ </SidebarProvider>
193
+ )
194
+ }
@@ -0,0 +1,42 @@
1
+ import { sql } from 'mithaq/drizzle'
2
+ import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
3
+ import { serverTargets } from '@asas/mithaq'
4
+
5
+ /** A write's transaction, which is drizzle's here. */
6
+ const txOf = (tx: unknown) => tx as NodePgDatabase
7
+
8
+ /**
9
+ * How many seats of a session are held, a withdrawn one not among them. Counted through the store, past the
10
+ * reader's own access, which a student's narrows to its own seat: a caller reads the session under that access first.
11
+ */
12
+ export const seatsTaken = async ({ session, tx }: { session: string; tx?: unknown }) =>
13
+ (await serverTargets.drizzle.find({
14
+ tx,
15
+ kayan: 'enrollments',
16
+ args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, where: { and: [{ session: { equals: session } }, { deletedAt: { exists: false } }] } },
17
+ })).totalDocs
18
+
19
+ /**
20
+ * Serializes every write that takes a seat in one session until its transaction ends: a second write waits here,
21
+ * and under Read Committed its count, a statement after the lock, sees what the first one committed, so two
22
+ * writes never both take the last seat.
23
+ */
24
+ export const holdSeats = async ({ tx, session }: { tx: unknown; session: string }) => {
25
+ await txOf(tx).execute(sql`select pg_advisory_xact_lock(hashtext('mithaq.seats'), hashtext(${session}))`)
26
+ }
27
+
28
+ /**
29
+ * What a seat is judged by, read off the columns in the write's transaction rather than through the actor's
30
+ * projection: a role shown no capacity, start or archived mark would otherwise read none and skip each rule. A
31
+ * caller reads the session under the actor's access first, so this reads only a session the actor may reach. Its
32
+ * `id` is the stored one, the same in every case the caller wrote it. Both rows stay locked before the
33
+ * enrollment's reference check, so an archive or capacity change cannot pass the decision, and the later
34
+ * `FOR KEY SHARE` does not require a lock upgrade. `FOR SHARE` lets enrollments share those rows while
35
+ * holding off their changes; `holdSeats` serializes the seat count for a session with a capacity.
36
+ *
37
+ * The tables and columns are written out, as the drizzle target names them; a rename fails R7a, R7b and R7f.
38
+ */
39
+ export const sessionHeld = async ({ tx, session }: { tx: unknown; session: string }) =>
40
+ (await txOf(tx).execute<{ id: string; capacity: number | null; startsAt: Date; archived: boolean | null; courseArchived: boolean | null; selfEnroll: boolean | null }>(sql`
41
+ select s.id, s.capacity::float8 as capacity, s.starts_at as "startsAt", s.archived, c.archived as "courseArchived", c.self_enroll as "selfEnroll"
42
+ from course_sessions s join courses c on c.id = s.course_id where s.id = ${session} for share of s, c`)).rows[0]
@@ -0,0 +1,186 @@
1
+ import { defaultFields, defaultOperations, type Ctx, type Where } from 'mithaq'
2
+ import { defineKayan, organization } from '@asas/mithaq'
3
+ import { activity, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
4
+ import { courses, runsCatalogue } from '../courses/courses.ts'
5
+ import { archivedRow, marks, reason, reasonInput, unarchived } from '../domain/archive.ts'
6
+
7
+ /**
8
+ * Hands each matched session's `archive` no reason and nothing else, whatever the call sent as
9
+ * `data`, so the course page's bulk Archive can only archive, never un-archive.
10
+ */
11
+ const archiveAll = (ctx: Ctx): Ctx => ({ ...ctx, data: { ...ctx.data, data: { reason: '' } } })
12
+
13
+ /**
14
+ * What a session's create and edit pages both draw, written once so the two cannot drift. The edit
15
+ * page keeps the course on the side; the create page puts it first in the form, because it is
16
+ * required and a side column sits after Save.
17
+ */
18
+ const details = [
19
+ { section: 'schedule', label: { en: 'Schedule', ar: 'الموعد' }, fields: [{ row: ['startsAt', 'capacity'] }] },
20
+ { section: 'place', label: { en: 'Venue and format', ar: 'المكان والصيغة' }, fields: [{ row: ['venue', 'format'] }] },
21
+ { section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['details'] },
22
+ ] as const
23
+ const course = { section: 'course', label: { en: 'Course', ar: 'الدورة' }, fields: ['course'] } as const
24
+
25
+ /**
26
+ * A session an actor reads, inside its active organization: one whose course that
27
+ * actor reads. Whoever runs the catalogue reads every session; a student or an instructor reads a
28
+ * session only when its course is published and not archived, and never the date of one it may not
29
+ * open.
30
+ */
31
+ const ofReadableCourses = ({ user }: { user: unknown }): true | Where =>
32
+ runsCatalogue(user)
33
+ ? true
34
+ : { and: [{ 'course._status': { equals: 'published' } }, { or: [{ 'course.archived': { exists: false } }, { 'course.archived': { equals: false } }] }] }
35
+
36
+ export const sessions = defineKayan({
37
+ name: 'sessions',
38
+ // ANDed with the inherited rows, so it narrows and never widens.
39
+ access: { read: ofReadableCourses },
40
+ // Better Auth owns a table named `session`, and `session` beside `sessions` in
41
+ // one schema is a trap.
42
+ dbName: 'course_sessions',
43
+ targets: {
44
+ backoffice: {
45
+ useAsTitle: 'startsAt',
46
+ pages: {
47
+ ...backofficeList('/sessions', { columns: ['startsAt', 'organization', 'course', 'capacity', 'venue'], filters: ['organization', 'course', 'venue', 'archived'], search: ['venue', 'course.title'], sort: '-startsAt' }),
48
+ ...backofficeRecord({
49
+ path: '/sessions/:id',
50
+ main: [{ tabs: [
51
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
52
+ { section: 'schedule', label: { en: 'Schedule', ar: 'الموعد' }, fields: [{ row: ['startsAt', 'capacity'] }] },
53
+ { section: 'place', label: { en: 'Venue and format', ar: 'المكان والصيغة' }, fields: [{ row: ['venue', 'format'] }] },
54
+ { section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['details'] },
55
+ ] },
56
+ { tab: 'enrollments', label: { en: 'Enrollments', ar: 'التسجيلات' }, fields: [{ list: { of: 'enrollments', where: 'session', columns: ['student', 'createdAt'] } }] },
57
+ ] }],
58
+ side: [{ section: 'course', label: { en: 'Course', ar: 'الدورة' }, fields: ['course', 'archived'] }],
59
+ }),
60
+ },
61
+ },
62
+ // The portal lists sessions for this alone: an enrollment's session reads by its start there as it
63
+ // does on the dashboard.
64
+ portal: { useAsTitle: 'startsAt' },
65
+ dashboard: {
66
+ // What a session is shown by wherever it stands for itself: the picker on `enrollments.session`
67
+ // and a relationship column pointing here. A session declares no name, and its start tells two
68
+ // sessions of one course apart, drawn in the viewer's own format. The search boxes come to
69
+ // nothing, because `like` takes text and email.
70
+ useAsTitle: 'startsAt',
71
+ description: {
72
+ en: 'The dates each course meets, and how many seats each one holds.',
73
+ ar: 'مواعيد انعقاد كل دورة، وعدد المقاعد في كل منها.',
74
+ },
75
+ pages: {
76
+ list: { path: '/sessions', page: appPage([{ layout: 'table', main: [{ list: {
77
+ columns: ['course', 'startsAt', 'capacity', 'venue'],
78
+ filters: ['course', 'startsAt', 'venue', 'format', 'archived'],
79
+ search: ['course.title', 'details'],
80
+ } }] }]) },
81
+ create: {
82
+ path: '/sessions/new',
83
+ page: appPage([{ layout: 'record', main: [{ form: [course, ...details] }] }]),
84
+ },
85
+ edit: {
86
+ path: '/sessions/:id',
87
+ page: appPage([{
88
+ layout: 'record',
89
+ main: [{ tabs: [
90
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{ form: details }] },
91
+ // Drawn only for an actor the report lets read them: a tab whose only node is hidden is not
92
+ // drawn, so a registrar is not shown a tab with nothing in it.
93
+ { tab: 'enrollments', label: { en: 'Enrollments', ar: 'التسجيلات' }, fields: [{ list: {
94
+ of: 'enrollments',
95
+ where: 'session',
96
+ columns: ['student', 'createdAt'],
97
+ }, when: { '$user.collections.enrollments.operations.find': { equals: true } } }] },
98
+ ] }],
99
+ // Whether it is archived and why, read only: a session is archived through its own operations.
100
+ side: [{ ...course, fields: [...course.fields, { field: 'archived', readOnly: true }, { field: 'archiveReason', readOnly: true }] }, activity],
101
+ // Archived and brought back as a course is, each with its reason, before the delete.
102
+ danger: danger({
103
+ button: 'archive',
104
+ when: unarchived,
105
+ label: { en: 'Archive', ar: 'أرشفة' },
106
+ fields: ['reason'],
107
+ reload: true,
108
+ confirm: { title: { en: 'Archive this session?', ar: 'أرشفة هذه الجلسة؟' }, body: { en: 'It stays on the course, marked archived.', ar: 'تبقى في الدورة، معلمة بأنها مؤرشفة.' } },
109
+ }, {
110
+ button: 'unarchive',
111
+ when: archivedRow,
112
+ label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' },
113
+ fields: ['reason'],
114
+ reload: true,
115
+ confirm: { title: { en: 'Unarchive this session?', ar: 'إلغاء أرشفة هذه الجلسة؟' }, body: { en: 'It is open again as it was.', ar: 'تعود مفتوحة كما كانت.' } },
116
+ }),
117
+ }]),
118
+ },
119
+ },
120
+ },
121
+ service: {
122
+ operations: {
123
+ ...defaultOperations,
124
+ // One `archive` per matched row under the same access, so the mark is written by an
125
+ // archive alone.
126
+ archiveMany: { each: 'archive', steps: ({ steps }) => [archiveAll, steps.updateMany] },
127
+ archive: {
128
+ targets: { dashboard: { fields: { reason } } },
129
+ input: () => reasonInput('sessions.operations.archive'),
130
+ steps: ({ steps }) => [marks(true), steps.update],
131
+ },
132
+ unarchive: {
133
+ targets: { dashboard: { fields: { reason } } },
134
+ input: () => reasonInput('sessions.operations.unarchive'),
135
+ steps: ({ steps }) => [marks(false), steps.update],
136
+ },
137
+ },
138
+ },
139
+ },
140
+ fields: {
141
+ ...defaultFields,
142
+ organization,
143
+ course: {
144
+ kind: 'relationship',
145
+ label: { en: 'Course', ar: 'الدورة' },
146
+ relationTo: () => courses,
147
+ rules: { required: true },
148
+ },
149
+ startsAt: { kind: 'datetime', label: { en: 'Starts at', ar: 'وقت البداية' }, rules: { required: true } },
150
+ // A session with room for nobody is not a session, so one is the floor.
151
+ capacity: { kind: 'number', label: { en: 'Capacity', ar: 'السعة' }, rules: { min: 1 } },
152
+ venue: {
153
+ kind: 'text', label: { en: 'Venue', ar: 'المكان' },
154
+ options: [
155
+ { value: 'online', label: { en: 'Online', ar: 'عبر الإنترنت' } },
156
+ { value: 'campus', label: { en: 'Campus', ar: 'الحرم' } },
157
+ ],
158
+ targets: { dashboard: { component: 'nativeSelect' } },
159
+ },
160
+ format: {
161
+ kind: 'text', label: { en: 'Format', ar: 'الصيغة' },
162
+ options: [
163
+ { value: 'workshop', label: { en: 'Workshop', ar: 'ورشة عمل' } },
164
+ { value: 'lecture', label: { en: 'Lecture', ar: 'محاضرة' } },
165
+ ],
166
+ targets: { dashboard: { component: 'combobox' } },
167
+ },
168
+ // Written by `archive` and `unarchive` alone, as a course's is, and `archiveMany` runs an archive per row.
169
+ archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشفة' }, rules: { providedBy: { create: 'never', update: 'application' } } },
170
+ details: { kind: 'richText', label: { en: 'Details', ar: 'التفاصيل' } },
171
+ /**
172
+ * The enrollments that point at this session: the one kind that builds no column. `enrollments.session`
173
+ * holds this row's id, so a read populates the array through the enrollments kayan's own access and
174
+ * soft-delete filter, and a read that names no `depth` populates none of it.
175
+ */
176
+ enrollments: {
177
+ kind: 'join',
178
+ relationTo: 'enrollments',
179
+ on: 'session',
180
+ label: { en: 'Enrollments', ar: 'التسجيلات' },
181
+ },
182
+ // Why it was last archived or brought back. Declared last, so a pushed table and a migrated one
183
+ // hold the column in the same place.
184
+ archiveReason: { kind: 'text', label: { en: 'Archive reason', ar: 'سبب الأرشفة' }, rules: { maxLength: 200, providedBy: { create: 'never', update: 'application' } } },
185
+ },
186
+ })
@@ -0,0 +1,10 @@
1
+ import { media, type ImageRead } from './media/media.ts'
2
+
3
+ export type { ImageRead }
4
+
5
+ /**
6
+ * The feature's kayan, built for the reads beside `media.create` that open an
7
+ * image, which each feature whose rows point at images brings and the asas
8
+ * hands in. With none, only a holder of `media.create` reads an image.
9
+ */
10
+ export const kayan = ({ reads }: { reads: readonly ImageRead[] }) => [media({ reads })] as const
@@ -0,0 +1,131 @@
1
+ import { defaultFields, defaultOperations, folderOf, uploadFields, withUploadReference, type Where } from 'mithaq'
2
+ import { defineKayan, organization } from '@asas/mithaq'
3
+ import { readsPast } from '@asas/mithaq/roles/tenant-access.ts'
4
+ import { appPage, danger } from '@asas/mithaq/mithaq/pages.ts'
5
+ import { storageConfig } from '@asas/mithaq/storage/index.ts'
6
+
7
+ const ui = folderOf(new URL('./ui/', import.meta.url))
8
+
9
+ /**
10
+ * One way an actor that holds no `media.create` reads an image: the images it
11
+ * may read, or `false` for none. Never `true`, which would open every image of
12
+ * the organization to whoever the read answered it for.
13
+ */
14
+ export type ImageRead = (args: { user: unknown }) => Promise<Where | false>
15
+
16
+ /**
17
+ * The images an actor reads, inside its active organization: every image of
18
+ * its organization where it holds `media.create`, and otherwise only those one
19
+ * of `reads` answers. `media.create` is the line because a role that adds
20
+ * images has to find one before anything names it, in an upload field's
21
+ * picker. A file at `GET /api/media/file/<id>` is read through this kayan's
22
+ * own `findByID`, so the same answer decides who downloads it.
23
+ */
24
+ const imagesReadBy = (reads: readonly ImageRead[]) => async ({ user }: { user: unknown }) => {
25
+ if (readsPast(user, 'media.create')) return true
26
+ const answered: unknown[] = await Promise.all(reads.map((read) => read({ user })))
27
+ // The type forbids it, but a cast `true` or a filter of nothing would open every image of the
28
+ // organization, so either throws and the actor reads none.
29
+ for (const [at, answer] of answered.entries()) {
30
+ if (answer !== false && (!answer || typeof answer !== 'object' || !Object.keys(answer).length)) {
31
+ throw new Error(`The image read ${reads[at]!.name || `at ${at}`} answered ${JSON.stringify(answer)}, which would open every image; a read answers false or a filter.`)
32
+ }
33
+ }
34
+ const answers = answered.filter((one): one is Where => one !== false)
35
+ if (!answers.length) return false
36
+ return answers.length === 1 ? answers[0]! : { or: answers }
37
+ }
38
+
39
+ /**
40
+ * The organization's images: a row per file, whose object is kept where the
41
+ * project's storage puts it. An upload field points at one, as a course's cover does.
42
+ *
43
+ * It spreads `uploadFields`, the columns the create writes from the upload it
44
+ * verified and nobody writes after, and declares its own create and delete:
45
+ * the create takes the signed `uploadReference` that
46
+ * `POST /api/media/uploadInstructions` handed out and runs the project's
47
+ * `uploaded`, and the delete runs its `removed` after the commit. `buildAsas`
48
+ * refuses a create that skips the configured `uploaded`. A file is
49
+ * read at `GET /api/media/file/<id>` through this kayan's own `findByID`, so
50
+ * its organization and `reads` decide who reads it. It keeps no versions, since a file never changes once it is
51
+ * made; a kayan pointing at one keeps the versions of that pointer instead.
52
+ */
53
+ export const media = ({ reads }: { reads: readonly ImageRead[] }) => defineKayan({
54
+ name: 'media',
55
+ // ANDed with the inherited rows, so it narrows and never widens.
56
+ access: { read: imagesReadBy(reads) },
57
+ messages: {
58
+ singular: { en: 'Image', ar: 'صورة' },
59
+ plural: { en: 'Images', ar: 'الصور' },
60
+ },
61
+ targets: {
62
+ storage: { mimeTypes: ['image/png', 'image/jpeg', 'image/webp'] },
63
+ // A file is named by its name wherever it stands for itself: the upload
64
+ // control's picker and a relationship column pointing here.
65
+ portal: { useAsTitle: 'filename' },
66
+ // The organization's images, listed and re-described. A file is made
67
+ // where it is used, by the upload control, and on this kayan's own New
68
+ // page, both through the upload dialog, which sends it before the row is
69
+ // made. The list draws each image's thumbnail beside its name and its size as a
70
+ // person reads it. The file's own columns are the runner's, so the edit
71
+ // page draws them read-only, with the image, its size, Download and
72
+ // Replace file beside the form, and above it on a phone.
73
+ dashboard: {
74
+ useAsTitle: 'filename',
75
+ description: { en: 'The images this organization uses, each with the words a screen reader says for it.', ar: 'الصور التي تستخدمها هذه المؤسسة، ولكل منها الكلمات التي يقرؤها قارئ الشاشة.' },
76
+ pages: {
77
+ list: { path: '/media', page: appPage([{ layout: 'table', main: [{ list: {
78
+ columns: [{ field: 'filename', cell: ui('preview.tsx#FileName') }, 'alt', 'mimeType', { field: 'filesize', cell: ui('preview.tsx#FileSize') }, 'createdAt'],
79
+ search: ['filename'],
80
+ sort: '-createdAt',
81
+ } }] }]) },
82
+ create: { path: '/media/new', component: ui('preview.tsx#NewImage') },
83
+ edit: {
84
+ path: '/media/:id',
85
+ page: appPage([{
86
+ layout: 'record',
87
+ main: [{ form: [
88
+ { section: 'description', label: { en: 'Description', ar: 'الوصف' }, fields: ['alt'] },
89
+ { section: 'file', label: { en: 'File', ar: 'الملف' }, fields: ['filename', 'mimeType', { row: ['width', 'height'] }] },
90
+ ] }],
91
+ side: [{ component: ui('preview.tsx#MediaPreview') }],
92
+ danger: danger(),
93
+ }]),
94
+ },
95
+ },
96
+ },
97
+ service: {
98
+ operations: {
99
+ ...defaultOperations,
100
+ // The upload inside the create. Before the transaction opens, the
101
+ // signed reference is checked and the stored object is read back,
102
+ // and every file column is written from what the server measured.
103
+ // Then the row is made in one insert.
104
+ create: {
105
+ ...defaultOperations.create,
106
+ input: ({ create }) => withUploadReference(create),
107
+ beforeTransaction: [storageConfig.uploaded],
108
+ },
109
+ // The object is removed only once the row's removal has committed,
110
+ // so a failure leaves an orphan for the sweep and never a row that
111
+ // names nothing.
112
+ delete: {
113
+ ...defaultOperations.delete,
114
+ afterCommit: ({ afterCommit }) => [...afterCommit, storageConfig.removed],
115
+ },
116
+ },
117
+ },
118
+ },
119
+ fields: {
120
+ ...defaultFields,
121
+ organization,
122
+ ...uploadFields,
123
+ // What a screen reader says for the image, in every locale, since an
124
+ // image with no words is no image to someone who cannot see it.
125
+ alt: {
126
+ kind: 'text',
127
+ label: { en: 'Alternative text', ar: 'النص البديل' },
128
+ rules: { localized: true, required: true, maxLength: 300 },
129
+ },
130
+ },
131
+ })