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.
Files changed (357) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/payload.txt +22 -0
  3. package/LICENSES/ponytail.txt +21 -0
  4. package/index.js +127 -0
  5. package/package.json +19 -4
  6. package/template/apps/asas-platform/components.json +25 -0
  7. package/template/apps/asas-platform/gitignore +3 -0
  8. package/template/apps/asas-platform/package.json +55 -0
  9. package/template/apps/asas-platform/public/favicon.svg +1 -0
  10. package/template/apps/asas-platform/src/api.ts +147 -0
  11. package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
  12. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
  13. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
  14. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
  15. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
  16. package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
  17. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
  18. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
  19. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
  20. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
  21. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
  22. package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
  23. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
  24. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
  25. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
  26. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
  27. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
  28. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
  29. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
  30. package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
  31. package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
  32. package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
  33. package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
  34. package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
  35. package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
  36. package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
  37. package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
  38. package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
  39. package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
  40. package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
  41. package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
  42. package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
  43. package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
  44. package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
  45. package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
  46. package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
  47. package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
  48. package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
  49. package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
  50. package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
  51. package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
  52. package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
  53. package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
  54. package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
  55. package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
  56. package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
  57. package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
  58. package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
  59. package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
  60. package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
  61. package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
  62. package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
  63. package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
  64. package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
  65. package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
  66. package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
  67. package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
  68. package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
  69. package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
  70. package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
  71. package/template/apps/asas-platform/src/copy.ts +1 -0
  72. package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
  73. package/template/apps/asas-platform/src/lib/utils.ts +1 -0
  74. package/template/apps/asas-platform/src/links.ts +38 -0
  75. package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
  76. package/template/apps/asas-platform/src/redirect.ts +1 -0
  77. package/template/apps/asas-platform/src/request-headers.ts +11 -0
  78. package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
  79. package/template/apps/asas-platform/src/router.tsx +7 -0
  80. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
  81. package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
  82. package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
  83. package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
  84. package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
  85. package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
  86. package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
  87. package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
  88. package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
  89. package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
  90. package/template/apps/asas-platform/src/screen-form.tsx +34 -0
  91. package/template/apps/asas-platform/src/screen-list.tsx +139 -0
  92. package/template/apps/asas-platform/src/screen-names.ts +10 -0
  93. package/template/apps/asas-platform/src/screens.tsx +868 -0
  94. package/template/apps/asas-platform/src/server.ts +20 -0
  95. package/template/apps/asas-platform/src/slide.ts +22 -0
  96. package/template/apps/asas-platform/src/start.ts +42 -0
  97. package/template/apps/asas-platform/src/styles.css +200 -0
  98. package/template/apps/asas-platform/src/theme.ts +2 -0
  99. package/template/apps/asas-platform/tsconfig.json +19 -0
  100. package/template/apps/asas-platform/vite.config.ts +26 -0
  101. package/template/apps/asas-tanstack/components.json +25 -0
  102. package/template/apps/asas-tanstack/gitignore +3 -0
  103. package/template/apps/asas-tanstack/package.json +56 -0
  104. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  105. package/template/apps/asas-tanstack/src/api.ts +147 -0
  106. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  107. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
  108. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
  109. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
  110. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
  111. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
  112. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
  113. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
  114. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
  115. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
  116. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
  117. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
  118. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
  119. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
  120. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
  121. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
  122. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
  123. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
  124. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
  125. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  126. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  127. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  128. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  129. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  130. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  131. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  132. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  133. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  134. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  135. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  136. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  137. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  138. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  139. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  140. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  141. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
  142. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  143. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  144. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  145. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  146. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  147. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  148. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  149. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  150. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  151. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  152. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  153. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  154. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  155. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  156. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  157. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  158. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  159. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  160. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  161. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  162. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  163. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  164. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  165. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  166. package/template/apps/asas-tanstack/src/copy.ts +870 -0
  167. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  168. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  169. package/template/apps/asas-tanstack/src/links.ts +38 -0
  170. package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
  171. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  172. package/template/apps/asas-tanstack/src/redirect.ts +20 -0
  173. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  174. package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
  175. package/template/apps/asas-tanstack/src/router.tsx +7 -0
  176. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
  177. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
  178. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
  179. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  180. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  181. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  182. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
  183. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
  184. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
  185. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  186. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  187. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  188. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  189. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  190. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
  191. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  192. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  193. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
  194. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
  195. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
  196. package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
  197. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
  198. package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
  199. package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
  200. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  201. package/template/apps/asas-tanstack/src/screens.tsx +868 -0
  202. package/template/apps/asas-tanstack/src/server.ts +16 -0
  203. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  204. package/template/apps/asas-tanstack/src/start.ts +57 -0
  205. package/template/apps/asas-tanstack/src/styles.css +159 -0
  206. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  207. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  208. package/template/apps/asas-tanstack/vite.config.ts +28 -0
  209. package/template/package.json +16 -0
  210. package/template/templates/asas/.env.example +16 -0
  211. package/template/templates/asas/components.json +22 -0
  212. package/template/templates/asas/mithaq.hints.json +25 -0
  213. package/template/templates/asas/package.json +38 -0
  214. package/template/templates/asas/src/asas.ts +93 -0
  215. package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
  216. package/template/templates/asas/src/audit-log/sink.ts +89 -0
  217. package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
  218. package/template/templates/asas/src/courses/courses.ts +612 -0
  219. package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
  220. package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
  221. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
  222. package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
  223. package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
  224. package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
  225. package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
  226. package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
  227. package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
  228. package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
  229. package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
  230. package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
  231. package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
  232. package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
  233. package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
  234. package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
  235. package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
  236. package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
  237. package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
  238. package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
  239. package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
  240. package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
  241. package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
  242. package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
  243. package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
  244. package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
  245. package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
  246. package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
  247. package/template/templates/asas/src/local-users/local-users.ts +143 -0
  248. package/template/templates/asas/src/media/media.ts +137 -0
  249. package/template/templates/asas/src/media/ui/preview.tsx +143 -0
  250. package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
  251. package/template/templates/asas/src/messages/account.json +106 -0
  252. package/template/templates/asas/src/messages/auditLog.json +186 -0
  253. package/template/templates/asas/src/messages/courses.json +270 -0
  254. package/template/templates/asas/src/messages/enrollments.json +42 -0
  255. package/template/templates/asas/src/messages/invitation.json +66 -0
  256. package/template/templates/asas/src/messages/localUsers.json +42 -0
  257. package/template/templates/asas/src/messages/media.json +66 -0
  258. package/template/templates/asas/src/messages/member.json +42 -0
  259. package/template/templates/asas/src/messages/memberRoles.json +42 -0
  260. package/template/templates/asas/src/messages/mithaq.json +170 -0
  261. package/template/templates/asas/src/messages/organization.json +50 -0
  262. package/template/templates/asas/src/messages/organizationRole.json +50 -0
  263. package/template/templates/asas/src/messages/roles.json +58 -0
  264. package/template/templates/asas/src/messages/session.json +82 -0
  265. package/template/templates/asas/src/messages/sessions.json +106 -0
  266. package/template/templates/asas/src/messages/settings.json +66 -0
  267. package/template/templates/asas/src/messages/tags.json +38 -0
  268. package/template/templates/asas/src/messages/user.json +90 -0
  269. package/template/templates/asas/src/messages/verification.json +50 -0
  270. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  271. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  272. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  273. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  274. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  275. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  276. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  277. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  278. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  279. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  280. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  281. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  282. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  283. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  284. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  285. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  286. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  287. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  288. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  289. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  290. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  291. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  292. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  293. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  294. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  295. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  296. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  297. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  298. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  299. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  300. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  301. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  302. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  303. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  304. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  305. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  306. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  307. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  308. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  309. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  310. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  311. package/template/templates/asas/src/migrations/schema.ts +29 -0
  312. package/template/templates/asas/src/mithaq.ts +406 -0
  313. package/template/templates/asas/src/pages.ts +74 -0
  314. package/template/templates/asas/src/platform-http.ts +96 -0
  315. package/template/templates/asas/src/rich-text-nodes.ts +11 -0
  316. package/template/templates/asas/src/roles/grants.ts +47 -0
  317. package/template/templates/asas/src/roles/roles.ts +184 -0
  318. package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
  319. package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
  320. package/template/templates/asas/src/roles.ts +16 -0
  321. package/template/templates/asas/src/seed.ts +325 -0
  322. package/template/templates/asas/src/sessions/sessions.ts +155 -0
  323. package/template/templates/asas/src/settings/settings.ts +69 -0
  324. package/template/templates/asas/src/statements.ts +11 -0
  325. package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
  326. package/template/templates/asas/src/storage/defaults.ts +156 -0
  327. package/template/templates/asas/src/storage/index.ts +95 -0
  328. package/template/templates/asas/src/storage/local.ts +143 -0
  329. package/template/templates/asas/src/storage/r2.ts +5 -0
  330. package/template/templates/asas/src/storage/s3.ts +91 -0
  331. package/template/templates/asas/src/storage/uploadthing.ts +59 -0
  332. package/template/templates/asas/src/tags/tags.ts +80 -0
  333. package/template/templates/asas/src/tenant-access.ts +221 -0
  334. package/template/templates/asas/src/tenant-roles.ts +262 -0
  335. package/template/templates/asas/src/ui/app-shell.tsx +260 -0
  336. package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
  337. package/template/templates/asas/src/ui/record-page.tsx +50 -0
  338. package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
  339. package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
  340. package/template/templates/asas/src/ui/session-labels.tsx +36 -0
  341. package/template/templates/asas/src/ui/table-page.tsx +9 -0
  342. package/template/templates/asas/src/ui/top-shell.tsx +60 -0
  343. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
  344. package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
  345. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  346. package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
  347. package/template/templates/asas/src/ui-platform/home.tsx +63 -0
  348. package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
  349. package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
  350. package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
  351. package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
  352. package/template/templates/asas/src/ui-portal/home.tsx +77 -0
  353. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  354. package/template/templates/auth/package.json +14 -0
  355. package/template/templates/auth/src/index.ts +690 -0
  356. package/template/templates/auth/src/platform-roles.ts +87 -0
  357. package/README.md +0 -3
@@ -0,0 +1,612 @@
1
+ import { defaultFields, draftFields, folderOf, invalid, versionOperations, type Ctx, type ReadTools, type WriteTools, defaultOperations } from 'mithaq'
2
+ import { z } from 'zod'
3
+ import { defineKayan, organization } from '../mithaq.ts'
4
+ import { activity, appPage, danger, platformList, platformRecord, topPage } from '../pages.ts'
5
+ import { readsPast } from '../tenant-access.ts'
6
+ import { sessions } from '../sessions/sessions.ts'
7
+ import { media } from '../media/media.ts'
8
+ import { onOffer } from './ui/on-offer.ts'
9
+ import { expectedUpdatedAt } from '../steps/expected-updated-at.ts'
10
+
11
+ /**
12
+ * Page pieces only this kayan draws with. What both screens share is reached
13
+ * through `ui`, `dashboardUi` and `portalUi` from `../mithaq.ts`.
14
+ */
15
+ const ui = folderOf(new URL('./ui/', import.meta.url))
16
+ /** What a fee column draws on every screen. */
17
+ const feeCell = ui('fee-cell.tsx#FeeCell')
18
+ /** What the list's Status column draws. */
19
+ const statusCell = ui('status-cell.tsx#StatusCell')
20
+
21
+ /**
22
+ * What `archive` parses. The error messages are this project's own copy keys: a
23
+ * refusal's message is the key the form resolves. Without them the form draws what zod
24
+ * wrote, in English.
25
+ */
26
+ const archiveInput = z.object({
27
+ id: z.guid(),
28
+ reason: z
29
+ .string({ error: 'courses.operations.archive.reason.required' })
30
+ .max(200, { error: 'courses.operations.archive.reason.maxLength' }),
31
+ })
32
+
33
+ /** The box `archive`'s dialog asks for on every screen that draws its button. */
34
+ const reason = {
35
+ kind: 'text',
36
+ label: { en: 'Reason', ar: 'السبب' },
37
+ rules: { required: true, maxLength: 200 },
38
+ } as const
39
+
40
+ /**
41
+ * The one step `archive` adds: it turns the operation's `reason` into the two
42
+ * columns the kayan has, so `reason` never has to be a field.
43
+ */
44
+ const markArchived = (ctx: Ctx<z.infer<typeof archiveInput>>): Ctx => {
45
+ const { reason } = ctx.data
46
+ // Spread `ctx.data` to keep what the runner filled before this step, such as
47
+ // `updatedAt`. `reason` reaches no column: the store step writes the kayan's own fields alone.
48
+ return { ...ctx, data: { ...ctx.data, archived: true, archiveReason: reason } }
49
+ }
50
+
51
+ /** When the access code is drawn: while the form holds a live delivery, redrawn as the delivery changes. */
52
+ const live = { delivery: { equals: 'live' } }
53
+ /** When an archive button is drawn: over a row not archived yet, on every page that draws one. */
54
+ const unarchived = { or: [{ 'record.archived': { exists: false } }, { 'record.archived': { equals: false } }] }
55
+
56
+ /** Whether the actor runs the catalogue and so reads every course and every session. */
57
+ export const runsCatalogue = (user: unknown): boolean => readsPast(user, 'courses.archive') || readsPast(user, 'screen.dashboard')
58
+
59
+ /**
60
+ * Archived courses are closed to students and instructors and open to whoever
61
+ * runs the catalogue. The dashboard's screen key counts because the registrar
62
+ * reads archived courses there without holding `courses.archive`. Every read
63
+ * answers from this, including relationships; the portal's `baseFilter` only
64
+ * narrows its list. Both arms, since a row from before the column existed holds `null`.
65
+ */
66
+ const archivedToArchivers = ({ user }: { user: unknown }) =>
67
+ runsCatalogue(user) ? true : { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }
68
+
69
+ /**
70
+ * What a course's create and edit pages both draw, written once so the two
71
+ * cannot drift. The access code follows the delivery the form holds.
72
+ */
73
+ const details = [
74
+ { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
75
+ { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }] },
76
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'notifications'] },
77
+ ] as const
78
+ const catalogue = { section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags'] } as const
79
+ /**
80
+ * The edit page's Catalogue, with `archived` read only: a course is archived
81
+ * through `archive` alone, which records its reason. A create leaves it out.
82
+ */
83
+ const archivedCatalogue = { ...catalogue, fields: [...catalogue.fields, { field: 'archived', readOnly: true }] } as const
84
+
85
+ /** What a course's create operation accepts for each session it makes with it. */
86
+ const sessionInput = z.object({ startsAt: z.date(), capacity: z.number().nullish() })
87
+
88
+ /**
89
+ * The step after `insert`. Each session is one managed write through
90
+ * `tools.of(sessions)`, which carries the actor, the depth counter and the
91
+ * transaction, so a session that throws rolls the course back.
92
+ *
93
+ * `sessions` imports this module for its `course` thunk, so the parameters and
94
+ * the return are annotated: inferring them from that call would make the two
95
+ * kayans' types circular.
96
+ */
97
+ const createSessions = async (
98
+ ctx: Ctx<{ id?: string; _status?: string | null; sessions?: readonly z.infer<typeof sessionInput>[] }>,
99
+ tools: WriteTools,
100
+ ): Promise<Ctx> => {
101
+ const { id, _status, sessions: made } = ctx.data
102
+ if (!made?.length) return ctx
103
+ // A draft is read by no plain read, so a session pointing at it would name a
104
+ // course that read cannot show. The runner has written `_status` by now; the
105
+ // throw rolls the course back.
106
+ if (_status === 'draft') {
107
+ throw invalid({
108
+ message: 'A course saved as a draft makes no sessions. Publish the course, then add its sessions.',
109
+ key: 'error.invalid.draft_sessions',
110
+ params: { kayan: 'courses' },
111
+ })
112
+ }
113
+ // The insert step has run, so `id` is set; the create shape types it optional
114
+ // because a caller may leave it to the column's default.
115
+ if (!id) throw new Error('The course was inserted with no id, so its sessions have nothing to point at.')
116
+ for (const session of made) {
117
+ await tools.of(sessions).create({ ...session, course: id })
118
+ }
119
+ return ctx
120
+ }
121
+
122
+ /** What `findWithSessions` puts on the course, one entry per session it may read. */
123
+ const sessionRow = z.object({
124
+ id: z.guid(),
125
+ startsAt: z.date(),
126
+ capacity: z.number().nullable(),
127
+ })
128
+
129
+ /**
130
+ * The step after `findOne`. The course is loaded by then, so its sessions are
131
+ * one managed read through `tools.of(sessions)`, which runs the sessions kayan's own
132
+ * access, so a session this actor may not read is not in what comes back. A
133
+ * limit of zero is no page limit.
134
+ */
135
+ const attachSessions = async (ctx: Ctx, tools: ReadTools): Promise<Ctx> => {
136
+ // `ctx.data` is typed as what the operation parses, but this step runs after
137
+ // `findOne`, which leaves the row or `null`: narrow it rather than cast.
138
+ const course: unknown = ctx.data
139
+ if (!course || typeof course !== 'object' || !('id' in course) || typeof course.id !== 'string') return ctx
140
+ const found = await tools.of(sessions).find({
141
+ where: { course: { equals: course.id } },
142
+ limit: 0,
143
+ })
144
+ return { ...ctx, data: { ...course, sessions: found.docs } }
145
+ }
146
+
147
+ export const courses = defineKayan({
148
+ name: 'courses',
149
+ // ANDed with the inherited rows, so it narrows and never widens.
150
+ access: { read: archivedToArchivers },
151
+ /**
152
+ * The singular and plural names are reserved keys of the kayan's own copy
153
+ * scope. The nav and every screen's heading read them, and a kayan that
154
+ * declares neither is shown under its own name.
155
+ */
156
+ messages: {
157
+ singular: { en: 'Course', ar: 'دورة' },
158
+ plural: { en: 'Courses', ar: 'الدورات' },
159
+ // Nested under the field's own name so that name is written once; `buildAsas`
160
+ // joins the parts into `fee.invalid` and `fee.min`. `fee` carries its own zod
161
+ // shape, so mithaq seeds neither key: declaring them here keeps
162
+ // `mithaq generate messages` from pruning them.
163
+ fee: {
164
+ invalid: { en: 'A fee is a number.', ar: 'الرسوم رقم.' },
165
+ min: { en: 'A fee is never negative.', ar: 'الرسوم لا تكون سالبة أبدًا.' },
166
+ },
167
+ },
168
+ targets: {
169
+ /** Every organization's rows on the platform, read-only, with each row's record page and its sessions. */
170
+ platform: {
171
+ useAsTitle: 'title',
172
+ pages: {
173
+ ...platformList('/courses', { columns: ['title', 'organization', 'fee', 'archived', 'createdAt'], filters: ['organization', 'title', 'fee', 'archived'], search: ['title'], sort: '-createdAt' }),
174
+ ...platformRecord({
175
+ path: '/courses/:id',
176
+ main: [{ tabs: [
177
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
178
+ { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'details'] },
179
+ { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, 'audience', 'topics'] },
180
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', 'accessCode', 'level', 'opensOn'] },
181
+ { section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['archiveReason'] },
182
+ ] },
183
+ { tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
184
+ of: 'sessions',
185
+ where: 'course',
186
+ columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
187
+ filters: ['venue', 'archived'],
188
+ search: ['venue'],
189
+ sort: 'startsAt',
190
+ } }] },
191
+ ] }],
192
+ side: [{ section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags', 'archived', 'notifications'] }],
193
+ }),
194
+ },
195
+ },
196
+ /**
197
+ * A course keeps a copy of itself on every write. A change may be saved as a
198
+ * draft, autosaved in place, before it is published; a plain read leaves a
199
+ * course never published out.
200
+ */
201
+ versions: { drafts: { autosave: true } },
202
+ /**
203
+ * The operations this kayan adds or restates. The store and schema they run
204
+ * through are the service target's own.
205
+ */
206
+ service: {
207
+ operations: {
208
+ ...defaultOperations,
209
+ // A course keeps its versions, so it answers the three that read and restore them.
210
+ ...versionOperations,
211
+ /**
212
+ * The shipped create list with one step added and one shape widened:
213
+ * `sessions` is part of the create signature without being a column, since
214
+ * the store step writes the kayan's own fields alone.
215
+ */
216
+ create: {
217
+ input: ({ create }) => create.extend({ sessions: z.array(sessionInput).optional() }),
218
+ steps: ({ steps }) => [steps.insert, createSessions],
219
+ },
220
+ /**
221
+ * The shipped update and delete lists with the `expectedUpdatedAt` step
222
+ * before each, and each input widened by the key the step reads. A draft
223
+ * reader holds the instant of the row's latest version, so a draft save,
224
+ * an autosave, a publish or a Discard is refused 409 when another writer
225
+ * saved since. The edit form sends the key on each; it stays optional, so
226
+ * a caller that sends none, a bulk among them, writes as before.
227
+ */
228
+ update: {
229
+ input: ({ update }) => update.extend({ expectedUpdatedAt: z.date().optional() }),
230
+ steps: ({ steps }) => [expectedUpdatedAt, steps.update],
231
+ },
232
+ delete: {
233
+ input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
234
+ steps: ({ steps }) => [expectedUpdatedAt, steps.remove],
235
+ },
236
+ /**
237
+ * A restore takes the key too, so Discard changes and a restore from the
238
+ * Versions tab are refused 409 over a newer draft another person saved,
239
+ * which a restore would otherwise bury.
240
+ */
241
+ restoreVersion: {
242
+ input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
243
+ steps: ({ steps }) => [expectedUpdatedAt, steps.restoreVersion],
244
+ },
245
+ /**
246
+ * A custom operation is one more list and gets what `create` gets: the
247
+ * transaction, the parse and the defaults. Its name is the action an access
248
+ * row grants. Naming `input` orders the two the runner owns: the caller's
249
+ * shape is parsed first and the defaults fill after it, so `updatedAt` moves
250
+ * as in the shipped `update` without this strict shape naming a key the
251
+ * caller never sends.
252
+ */
253
+ archive: {
254
+ // What a screen's button asks for beyond the row's id, said as fields: the
255
+ // dialog draws `reason` through the control every text field uses and keys
256
+ // its label `courses.operations.archive.reason`. `buildAsas` refuses a
257
+ // field it names no key for. The portal declares the same box, because a
258
+ // screen that declares none has no dialog, sends no reason and is refused.
259
+ targets: { dashboard: { fields: { reason } }, portal: { fields: { reason } } },
260
+ // The schema is named at the top of the file beside the step that reads it,
261
+ // so one shape types both the parse and `ctx.data`.
262
+ input: () => archiveInput,
263
+ steps: ({ steps }) => [markArchived, steps.update],
264
+ },
265
+ /**
266
+ * A read list with one step of its own and an `output` written the way
267
+ * `input` is, run once at `buildAsas`. It types `sessions` on the result,
268
+ * which no field declares and the row boundary does not know.
269
+ */
270
+ findWithSessions: {
271
+ // The id and nothing else, picked off the derived row shape. The row is
272
+ // stripped rather than strict, because it comes from the store; `.strict()`
273
+ // puts that refusal back.
274
+ input: ({ row }) => row.pick({ id: true }).strict(),
275
+ steps: ({ steps }) => [steps.findOne, attachSessions],
276
+ output: ({ row }) => row.extend({ sessions: z.array(sessionRow) }),
277
+ },
278
+ },
279
+ },
280
+ dashboard: {
281
+ // What this kayan is for, drawn under the list heading and the form title.
282
+ // One string per declared locale, keyed `courses.description`.
283
+ description: {
284
+ en: 'Everything on offer in the catalogue, and what a seat on each costs.',
285
+ ar: 'كل ما هو معروض في الدليل، وما يكلفه مقعد في كل منها.',
286
+ },
287
+ useAsTitle: 'title',
288
+ // Each page is one tree: the `app` shell at its root with the title in its
289
+ // header and the nav in its nav region, a layout in its main region, and what
290
+ // fills each region. Between them they draw a row, sections, tabs, a related
291
+ // list, a component, a danger-zone button that asks before it runs, and a
292
+ // read-only leaf. The board is rooted at the second shell, `top`, and names
293
+ // its layout and component by `ui()`.
294
+ pages: {
295
+ list: {
296
+ path: '/courses',
297
+ page: appPage([{ layout: 'table', main: [{ list: {
298
+ // `_status` says whether each row is a draft or published, since this
299
+ // list reads every row's latest draft.
300
+ columns: ['title', 'fee', 'archived', 'opensOn', '_status', 'details', 'cover'],
301
+ filters: ['title', 'fee', 'archived'],
302
+ // `details` is searched because this list names it: a rich text field
303
+ // offers itself to no search box on its own.
304
+ search: ['title', 'summary', 'details'],
305
+ empty: 'noCourses',
306
+ // Over the ticked rows: a delete behind its question, and one dialog that
307
+ // writes the fields it names over every one of them.
308
+ bulk: [
309
+ {
310
+ button: 'deleteMany',
311
+ label: { en: 'Delete', ar: 'حذف' },
312
+ confirm: {
313
+ title: { en: 'Delete the ticked courses?', ar: 'حذف الدورات المحددة؟' },
314
+ body: { en: 'This cannot be undone.', ar: 'لا يمكن التراجع عن هذا.' },
315
+ },
316
+ },
317
+ { button: 'updateMany', label: { en: 'Update', ar: 'تحديث' }, fields: ['fee', 'archived'] },
318
+ ],
319
+ // On each row not archived yet, the edit page's own archive,
320
+ // asking for its reason.
321
+ row: [{
322
+ button: 'archive',
323
+ when: unarchived,
324
+ label: { en: 'Archive', ar: 'أرشفة' },
325
+ fields: ['reason'],
326
+ }],
327
+ } }] }]),
328
+ },
329
+ // The edit page's Details and Catalogue, and nothing that needs a row yet:
330
+ // no Sessions or Versions tab, no activity, no danger zone.
331
+ create: {
332
+ path: '/courses/new',
333
+ page: appPage([{ layout: 'record', main: [{ form: details }], side: [catalogue] }]),
334
+ },
335
+ // The editor's tabs sit across the page, outside the cards. On Details a
336
+ // main column of sectioned fields has a side column beside it, and the danger
337
+ // zone is at its foot. Sessions and Versions take the page's full width, and
338
+ // the form's controls sit in the header on every tab. The page names every
339
+ // field it draws, and no id, tenant or timestamp.
340
+ edit: {
341
+ path: '/courses/:id',
342
+ page: appPage([{ tabs: [
343
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{
344
+ layout: 'record',
345
+ main: [{ form: details }],
346
+ // Beside the form, what was done to this row, by whom and when.
347
+ side: [archivedCatalogue, activity],
348
+ // Where a button sits is where it is drawn: the archive in the record
349
+ // layout's danger zone, asking before it runs and only over a row not
350
+ // archived yet, before the delete every edit page ends its danger zone with.
351
+ danger: danger({
352
+ button: 'archive',
353
+ when: unarchived,
354
+ label: { en: 'Archive', ar: 'أرشفة' },
355
+ fields: ['reason'],
356
+ reload: true,
357
+ confirm: {
358
+ title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' },
359
+ body: { en: 'It leaves the catalogue for every student.', ar: 'تخرج من الدليل لكل الطلاب.' },
360
+ },
361
+ }),
362
+ }] },
363
+ // The sessions are rows of another kayan pointing at this one: a list of
364
+ // `sessions` whose `course` is this row, searched, filtered and paged on
365
+ // the server, each row edited in place below the table or on its own page,
366
+ // and every write the sessions kayan's own, apart from this form's save.
367
+ { tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
368
+ of: 'sessions',
369
+ where: 'course',
370
+ columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
371
+ filters: ['venue', 'archived'],
372
+ search: ['venue'],
373
+ sort: 'startsAt',
374
+ bulk: [{
375
+ button: 'archiveMany',
376
+ label: { en: 'Archive', ar: 'أرشفة' },
377
+ confirm: {
378
+ title: { en: 'Archive the ticked sessions?', ar: 'أرشفة الجلسات المحددة؟' },
379
+ body: { en: 'They stay on the course, marked archived.', ar: 'تبقى في الدورة، معلمة بأنها مؤرشفة.' },
380
+ },
381
+ }],
382
+ } }] },
383
+ // The row's versions, newest first, each restored behind its confirm,
384
+ // drawn only for an actor holding the key that reads them; a tab whose
385
+ // every node is hidden is not drawn at all.
386
+ { tab: 'versions', label: { en: 'Versions', ar: 'الإصدارات' }, fields: [
387
+ { component: 'versions', when: { '$user.collections.courses.operations.findVersions': { equals: true } } },
388
+ ] },
389
+ ] }]),
390
+ },
391
+ board: {
392
+ path: '/courses/board',
393
+ page: { shell: 'top', header: [{ title: {} }], nav: [{ nav: {} }], main: [{ layout: 'table', main: [{ component: ui('no-courses.tsx#NoCourses') }] }] },
394
+ },
395
+ },
396
+ },
397
+ /**
398
+ * A shorter list and form than the dashboard's: `currency` as `radio` where
399
+ * the dashboard draws `select`, and `audience` as a row of buttons, from the
400
+ * one declaration of each field. Its row archive asks for a reason as the
401
+ * dashboard's does, and its edit page's archive is a component of the
402
+ * portal's own. It lists the courses on offer alone: an archived course is
403
+ * left out of its list and its home's count, and its page opens for no
404
+ * student or instructor.
405
+ */
406
+ portal: {
407
+ useAsTitle: 'title',
408
+ baseFilter: onOffer,
409
+ description: { en: 'The courses on offer.', ar: 'الدورات المعروضة.' },
410
+ pages: {
411
+ list: {
412
+ path: '/courses',
413
+ // Opens by title and 25 to a page, which the URL overrides, with
414
+ // the same archive on each row not archived yet.
415
+ page: topPage([{ layout: 'page', main: [{ list: {
416
+ columns: ['title', 'fee'],
417
+ filters: ['title'],
418
+ search: ['title'],
419
+ empty: ui('no-courses-on-offer.tsx#NoCourses'),
420
+ sort: 'title',
421
+ limit: 25,
422
+ row: [{
423
+ button: 'archive',
424
+ when: unarchived,
425
+ label: { en: 'Archive', ar: 'أرشفة' },
426
+ fields: ['reason'],
427
+ }],
428
+ } }] }]),
429
+ },
430
+ // The dashboard's record layout: the form's fields in sections, and the
431
+ // archive in the danger zone under it, only over a row not archived yet,
432
+ // drawn by the portal's own button, which writes the reason in the viewer's locale.
433
+ edit: {
434
+ path: '/courses/:id',
435
+ page: topPage([{
436
+ layout: 'record',
437
+ main: [{ form: [
438
+ { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
439
+ { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, { field: 'audience', component: 'buttonGroup' }] },
440
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'opensOn'] },
441
+ ] }],
442
+ // Drawn only for a viewer who may archive, so a danger zone with
443
+ // nothing in it is not drawn at all.
444
+ danger: [{
445
+ button: 'archive',
446
+ when: { and: [unarchived, { '$user.collections.courses.operations.archive': { equals: true } }] },
447
+ label: { en: 'Archive', ar: 'أرشفة' },
448
+ reload: true,
449
+ component: 'archive',
450
+ confirm: { title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' }, body: { en: 'Students stop seeing it.', ar: 'يتوقف الطلاب عن رؤيتها.' } },
451
+ }],
452
+ }]),
453
+ },
454
+ },
455
+ },
456
+ },
457
+ fields: {
458
+ ...defaultFields,
459
+ organization,
460
+ title: {
461
+ kind: 'text',
462
+ label: { en: 'Title', ar: 'العنوان' },
463
+ // What the field is and the hint its empty control shows, written once
464
+ // at the field's root and drawn the same on every screen. Both are copy,
465
+ // keyed `courses.title.description` and `courses.title.placeholder`.
466
+ description: { en: 'The name a student sees in the catalogue.', ar: 'الاسم الذي يراه الطالب في الدليل.' },
467
+ placeholder: { en: 'Introduction to Arabic calligraphy', ar: 'مقدمة في الخط العربي' },
468
+ // What the input group draws before the value, one string per locale,
469
+ // on every screen that draws the field.
470
+ prefix: { en: 'Course', ar: 'دورة' },
471
+ targets: {
472
+ dashboard: {
473
+ filter: true,
474
+ // What the list's search box runs a `like` over. One opt-in per
475
+ // field, so a name no field of this kayan carries cannot be written.
476
+ searchable: true,
477
+ },
478
+ },
479
+ // Trimmed before it is judged, so a title of spaces alone is refused as
480
+ // required on publish rather than saved as a course with no name. A draft
481
+ // save parses the kinds alone and keeps it.
482
+ rules: { required: true, trim: true, maxLength: 120, index: true },
483
+ },
484
+ // Optional, and carrying a length rule: the rule has to run on the string
485
+ // shape, which is only true while the shape is still a string.
486
+ summary: {
487
+ kind: 'text',
488
+ label: { en: 'Summary', ar: 'الملخص' },
489
+ rules: { maxLength: 200 },
490
+ // Searched beside the title, and never ordered by: a summary is a
491
+ // paragraph, so a page sorted by it reads as an arbitrary order, and a
492
+ // caller naming it is refused the way a caller naming a localized field is.
493
+ targets: { dashboard: { searchable: true, sortable: false } },
494
+ },
495
+ // A fee is never negative. `rules: { min: 0 }` would say so with the same
496
+ // two keys; the shape is this field's own on purpose, as the template's one
497
+ // field that carries its whole zod shape. It is `nullish` because the form
498
+ // sends `null` for a fee it cleared. The column stays derived.
499
+ //
500
+ // A shape written here is one mithaq wrote none of, so it names its own copy
501
+ // keys or an Arabic reader gets zod's English: `courses.fee.invalid` for a
502
+ // value that is not a number and `courses.fee.min` for a negative one.
503
+ // Neither is seeded, because the seed loop passes over a field whose schema
504
+ // target carries its own shape; this kayan's `messages` entry above claims both.
505
+ fee: {
506
+ kind: 'number',
507
+ label: { en: 'Fee', ar: 'الرسوم' },
508
+ // And after it: what one fee buys.
509
+ suffix: { en: 'per seat', ar: 'لكل مقعد' },
510
+ // A column reads the fee in the row's own currency on every screen
511
+ // A column reads the fee in the row's own currency on every screen.
512
+ targets: {
513
+ zod: z.number({ error: 'courses.fee.invalid' }).nonnegative({ error: 'courses.fee.min' }).nullish(),
514
+ dashboard: { filter: true, cell: feeCell },
515
+ portal: { cell: feeCell },
516
+ platform: { cell: feeCell },
517
+ },
518
+ },
519
+ currency: {
520
+ kind: 'text',
521
+ label: { en: 'Currency', ar: 'العملة' },
522
+ options: [
523
+ { value: 'SAR', label: { en: 'Saudi riyal', ar: 'ريال سعودي' } },
524
+ { value: 'USD', label: { en: 'US dollar', ar: 'دولار أمريكي' } },
525
+ ],
526
+ // One field, drawn as a `select` on the dashboard and as `radio` on the
527
+ // portal: what it holds is written once, and each screen says only how
528
+ // it draws it.
529
+ targets: { dashboard: { component: 'select' }, portal: { component: 'radio' } },
530
+ },
531
+ level: {
532
+ kind: 'number', label: { en: 'Level', ar: 'المستوى' }, rules: { min: 1, max: 5 },
533
+ targets: { dashboard: { component: 'slider' } },
534
+ },
535
+ notifications: {
536
+ kind: 'checkbox', label: { en: 'Notifications', ar: 'الإشعارات' },
537
+ targets: { dashboard: { component: 'switch', cell: 'checkbox' } },
538
+ },
539
+ delivery: {
540
+ kind: 'text', label: { en: 'Delivery', ar: 'طريقة التقديم' },
541
+ options: [
542
+ { value: 'live', label: { en: 'Live', ar: 'مباشر' } },
543
+ { value: 'recorded', label: { en: 'Recorded', ar: 'مسجل' } },
544
+ ],
545
+ // A choice on both screens, so each reads the option's label in the
546
+ // viewer's locale and never the stored value.
547
+ targets: { dashboard: { component: 'radio' }, portal: { component: 'radio' } },
548
+ },
549
+ audience: {
550
+ kind: 'text', label: { en: 'Audience', ar: 'الجمهور' },
551
+ options: [
552
+ { value: 'beginner', label: { en: 'Beginner', ar: 'مبتدئ' } },
553
+ { value: 'advanced', label: { en: 'Advanced', ar: 'متقدم' } },
554
+ ],
555
+ targets: { dashboard: { component: 'toggleGroup' } },
556
+ },
557
+ accessCode: {
558
+ kind: 'text', label: { en: 'Access code', ar: 'رمز الدخول' }, rules: { maxLength: 6 },
559
+ targets: { dashboard: { component: 'otp' } },
560
+ },
561
+ archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشف' }, targets: { dashboard: { filter: true } } },
562
+ // What a course covers, several at once out of a closed list. A course names
563
+ // at most two, so a third box ticked is refused before the save. No floor: an
564
+ // optional list left empty passes any `minRows`, and a list that is there
565
+ // holds one already.
566
+ topics: {
567
+ kind: 'textArray',
568
+ label: { en: 'Topics', ar: 'المواضيع' },
569
+ rules: { maxRows: 2 },
570
+ options: [
571
+ { value: 'script', label: { en: 'Script', ar: 'الخط' } },
572
+ { value: 'grammar', label: { en: 'Grammar', ar: 'النحو' } },
573
+ { value: 'recitation', label: { en: 'Recitation', ar: 'التلاوة' } },
574
+ ],
575
+ },
576
+ // The day enrolment opens, a calendar day with no time of its own, which
577
+ // the portal draws as the platform's own day picker.
578
+ opensOn: { kind: 'date', label: { en: 'Opens on', ar: 'يفتح في' } },
579
+ archiveReason: {
580
+ kind: 'text',
581
+ label: { en: 'Archive reason', ar: 'سبب الأرشفة' },
582
+ rules: { maxLength: 200 },
583
+ },
584
+ // The many-to-many: no column on this table at all, and a join table
585
+ // `courses_tags` the store writes in the same transaction the course is
586
+ // written in. The whole array replaces the old set on an update, and the join
587
+ // rows go with the course when it is deleted.
588
+ tags: {
589
+ kind: 'relationship',
590
+ relationTo: 'tags',
591
+ hasMany: true,
592
+ label: { en: 'Tags', ar: 'الوسوم' },
593
+ },
594
+ // The same rows a read with a depth populates. A page draws them as the list
595
+ // of `sessions` on the edit page's Sessions tab, and never as a leaf.
596
+ scheduledSessions: {
597
+ kind: 'join', relationTo: 'sessions', on: 'course',
598
+ label: { en: 'Scheduled sessions', ar: 'الجلسات المجدولة' },
599
+ },
600
+ // Whether this course is a draft or published; a kayan that keeps drafts spreads these last.
601
+ ...draftFields,
602
+ _status: { ...draftFields._status, targets: { dashboard: { cell: statusCell } } },
603
+ // What the course covers at length, as Lexical's editor state, drawn by the
604
+ // registry's editor and read by its viewer, and as its plain text in a list.
605
+ // Declared last so a pushed table and a migrated one hold the column in the same place.
606
+ details: { kind: 'richText', label: { en: 'Details', ar: 'التفاصيل' } },
607
+ // The course's cover, one of the organization's images. Its row cannot be
608
+ // deleted while a course points at it, since an upload restricts. Declared
609
+ // last, for the same column order.
610
+ cover: { kind: 'upload', relationTo: () => media, label: { en: 'Cover image', ar: 'صورة الغلاف' } },
611
+ },
612
+ })
@@ -0,0 +1,25 @@
1
+ // The portal's own button for `archive`, drawn in the edit page's danger zone
2
+ // as the registry draws its buttons: one click, behind the page's confirm,
3
+ // with the reason written for the viewer in the viewer's locale, where the
4
+ // dashboard asks for one in a dialog.
5
+ import type { ButtonProps } from 'mithaq/admin'
6
+ import { useLocale, useRecord, useUser } from 'mithaq/admin/react'
7
+ import { Button } from '@/components/ui/button'
8
+
9
+ export function ArchiveButton({ busy, operation, run }: ButtonProps) {
10
+ const { t } = useLocale()
11
+ const record = useRecord()
12
+ const user = useUser()
13
+ return (
14
+ <Button
15
+ type="button"
16
+ variant="outline"
17
+ data-archive-button={operation}
18
+ data-row={String(record?.id ?? '')}
19
+ disabled={busy || !record}
20
+ onClick={() => void run({ reason: t('courses.operations.archive.portalReason', 'Archived from the portal by {name}')!.replace('{name}', () => user.name) })}
21
+ >
22
+ {t('courses.operations.archive', 'Archive')}
23
+ </Button>
24
+ )
25
+ }
@@ -0,0 +1,19 @@
1
+ // A row with no currency, one the reader may not read, or one Intl does not know keeps the plain
2
+ // number. The amount is a left-to-right isolate so Arabic neither reads "US$" as "$US" nor strands
3
+ // the dot of "ر.س." on the wrong side.
4
+ import type { CellProps } from 'mithaq/admin'
5
+ import { useLocale } from 'mithaq/admin/react'
6
+ import { noValue } from '@/components/mithaq/controls.tsx'
7
+
8
+ const amount = (text: string) => <bdi dir="ltr">{text}</bdi>
9
+
10
+ export function FeeCell({ value, row }: CellProps) {
11
+ const { locale } = useLocale()
12
+ if (typeof value !== 'number') return noValue
13
+ try {
14
+ if (typeof row.currency === 'string' && row.currency) return amount(new Intl.NumberFormat(locale, { style: 'currency', currency: row.currency }).format(value))
15
+ } catch {
16
+ // A code Intl refuses throws a RangeError; the number alone is still true.
17
+ }
18
+ return amount(value.toLocaleString(locale))
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { EmptyProps } from 'mithaq/admin'
2
+ import { useKayan, useLocale } from 'mithaq/admin/react'
3
+
4
+ export function NoCourses({ page }: EmptyProps) {
5
+ const { t } = useLocale()
6
+ return (
7
+ <p data-empty={`${useKayan().name}${page.path}`} className="text-muted-foreground text-sm">
8
+ {t('courses.noneOnOffer', 'No courses on offer yet.')}
9
+ </p>
10
+ )
11
+ }