create-mithaq 0.1.1 → 0.2.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 (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1094 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
@@ -0,0 +1,309 @@
1
+ import { conflict, defaultOperations, folderOf, invalid, type Ctx, type FilterOptions, type WriteTools } from 'mithaq'
2
+ import { defineKayan, organization, serverTargets } from '@asas/mithaq'
3
+ import { currentOrganization, readsPast, user as actor } from '@asas/mithaq/roles/tenant-access.ts'
4
+ import { activity, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
5
+ import { sessions } from '../sessions/sessions.ts'
6
+ import { holdSeats, seatsTaken, sessionHeld } from '../sessions/seats.ts'
7
+ // add-on: settings
8
+ import { sql } from 'mithaq/drizzle'
9
+ import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
10
+ // end add-on: settings
11
+
12
+ const ui = folderOf(new URL('./ui/', import.meta.url))
13
+ const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
14
+ /** A portal page in its shell, which draws the portal's own nav, over its single column. */
15
+ const portalPage = <const Main extends readonly unknown[]>(main: Main) => ({ shell: 'top', header: [{ title: {} }], main: [{ layout: 'page', main }] }) as const
16
+ const sessionCell = ui('session-cell.tsx#SessionCell')
17
+ const enrollment = [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student'] }] as const
18
+
19
+ /**
20
+ * The seats an actor reads, edits and deletes, inside its active organization:
21
+ * every seat of its organization for an actor holding
22
+ * `enrollments.others` and for backoffice staff, its own seats alone for anyone
23
+ * else. That is the student, whose enrollment keys would otherwise reach every
24
+ * other student's seat, and a custom role a tenant writes without the key.
25
+ * Another's seat answers 404, as a missing row does.
26
+ *
27
+ * The id is read off the actor as an own key, and an actor carrying none reaches
28
+ * no seat. Every read answers from it, a session's join of its enrollments
29
+ * included.
30
+ */
31
+ const ownId = (user: unknown) => {
32
+ const actor = user && typeof user === 'object' ? (user as Record<string, unknown>) : undefined
33
+ const id = actor && Object.hasOwn(actor, 'id') ? actor.id : undefined
34
+ return typeof id === 'string' && id ? id : undefined
35
+ }
36
+ const ownSeats = ({ user }: { user: unknown }) => {
37
+ if (readsPast(user, 'enrollments.others')) return true
38
+ const id = ownId(user)
39
+ return id ? { student: { equals: id } } : false
40
+ }
41
+
42
+ /**
43
+ * Whether a write names another person as a seat's student for an actor that
44
+ * writes its own seats alone. Refused on a create and on an update, so a seat
45
+ * is never made for, or handed to, someone the actor may not write for.
46
+ */
47
+ const namesAnother = ({ user, data }: { user: unknown; data?: unknown }) =>
48
+ !readsPast(user, 'enrollments.others') && !!data && typeof data === 'object' && Object.hasOwn(data, 'student')
49
+ && (data as { student?: unknown }).student !== ownId(user)
50
+
51
+ /**
52
+ * Whom a seat's student picker offers: the people holding the student role in
53
+ * the active organization, a teacher who also studies among them. It narrows
54
+ * what the actor's own read of people answers and never widens it, so an
55
+ * instructor is offered the students it already reads and not itself.
56
+ */
57
+ const students: FilterOptions = async ({ user, tx }) => {
58
+ const active = currentOrganization(user)
59
+ if (!active) return false
60
+ const held = await serverTargets.drizzle.find({ tx, kayan: 'memberRoles', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: active }, role: { equals: 'student' } } } })
61
+ const ids = held.docs.map((row) => row.user).filter((id): id is string => typeof id === 'string')
62
+ return ids.length ? { id: { in: ids } } : false
63
+ }
64
+
65
+ /** Not archived, the column read by its name, or across a session's course. */
66
+ const open = (path: string) => ({ or: [{ [path]: { exists: false } }, { [path]: { equals: false } }] })
67
+
68
+ /** What a seat's session picker offers, and a save is held to: a session open for seats, of a course open too. */
69
+ const openSessions: FilterOptions = () => ({ and: [open('archived'), open('course.archived')] })
70
+
71
+ /** Whether the actor writes its own seats alone: a student, held to what each course lets its students do. */
72
+ const ownSeatsOnly = (user: unknown) => !readsPast(user, 'enrollments.others')
73
+
74
+ /** Whether a session is still to start, which a student's enrollment and its cancel both ask. */
75
+ const yetToStart = (startsAt: unknown) => new Date(startsAt as string | Date).getTime() > Date.now()
76
+
77
+ /**
78
+ * Whether a session takes this seat. A session the actor may not read is left
79
+ * to the reference check, which refuses it in the words a missing one gets.
80
+ * The rest is judged on the session's own columns, whatever the actor is shown:
81
+ * an archived session, or one of an archived course, takes none; a student
82
+ * takes a seat only where its course lets students enroll themselves, and only
83
+ * before the session starts; and a session with a capacity takes no seat past
84
+ * it, whoever writes, counted under a lock on the session. A seat kept in the
85
+ * session it already holds takes no new one.
86
+ */
87
+ const takesSeat = async (ctx: Ctx<{ session?: unknown }>, tools: WriteTools): Promise<Ctx> => {
88
+ const id = ctx.data.session
89
+ // An edit of a seat the actor may not reach loaded no row; the store answers it the 404 a missing one gets.
90
+ if (typeof id !== 'string' || (tools.operation !== 'create' && !ctx.dataBefore)) return ctx
91
+ if (!(await tools.of(sessions).findByID({ id }).catch(() => null))) return ctx
92
+ const session = await sessionHeld({ tx: ctx.tx, session: id })
93
+ if (!session) return ctx
94
+ // The stored id from here on: a uuid matches in any case, and the lock, the count and the comparison below must not
95
+ // tell `A0EE…` from `a0ee…`.
96
+ const held = session.id
97
+ if (session.archived || session.courseArchived) {
98
+ throw invalid({
99
+ message: 'This session is archived, or its course is, so it takes no new seat.',
100
+ key: 'error.invalid.archived_session',
101
+ params: { kayan: 'enrollments', field: 'session' },
102
+ })
103
+ }
104
+ if (held === ctx.dataBefore?.session) return ctx
105
+ if (ownSeatsOnly(ctx.user)) {
106
+ if (session.selfEnroll !== true) {
107
+ throw invalid({
108
+ message: 'Students of this course do not enroll themselves: the school enrolls them.',
109
+ key: 'error.invalid.enrollment_closed',
110
+ params: { kayan: 'enrollments', field: 'session' },
111
+ })
112
+ }
113
+ if (!yetToStart(session.startsAt)) {
114
+ throw invalid({ message: 'This session has started, so it takes no new seat.', key: 'error.invalid.session_started', params: { kayan: 'enrollments', field: 'session' } })
115
+ }
116
+ }
117
+ if (session.capacity !== null) {
118
+ await holdSeats({ tx: ctx.tx, session: held })
119
+ if ((await seatsTaken({ session: held, tx: ctx.tx })) >= session.capacity) {
120
+ throw conflict({ message: 'Every seat of this session is taken.', key: 'error.conflict.session_full', params: { kayan: 'enrollments', field: 'session' } })
121
+ }
122
+ }
123
+ return ctx
124
+ }
125
+
126
+ /**
127
+ * A student leaves a session only before it starts: the seat it held stays the
128
+ * record that it was there. Whoever reads every seat withdraws one at any time.
129
+ * A seat the actor may not reach loaded no row, and answers the 404 a missing
130
+ * one does. The start is the session's column, so a seat whose course has since
131
+ * left the actor's sight is not said to have started.
132
+ */
133
+ const leavesBeforeStart = async (ctx: Ctx): Promise<Ctx> => {
134
+ if (!ownSeatsOnly(ctx.user) || !ctx.dataBefore) return ctx
135
+ const id = ctx.dataBefore.session
136
+ const session = typeof id === 'string' ? await sessionHeld({ tx: ctx.tx, session: id }) : undefined
137
+ if (!session || !yetToStart(session.startsAt)) {
138
+ throw invalid({ message: 'This session has started, so its seat stays.', key: 'error.invalid.session_started', params: { kayan: 'enrollments' } })
139
+ }
140
+ return ctx
141
+ }
142
+
143
+ // add-on: settings
144
+ /**
145
+ * While the portal is closed for maintenance a student takes and gives back no
146
+ * seat, whatever calls the server; staff keep working. The organization's
147
+ * settings are read in the write's own transaction, since a student reads them
148
+ * as itself and a role might not.
149
+ */
150
+ // The table and column are written out, as `sessionHeld`'s are; R7g fails on a rename.
151
+ const portalOpen = async (ctx: Ctx): Promise<Ctx> => {
152
+ if (!ownSeatsOnly(ctx.user) || !ctx.tenant) return ctx
153
+ const [held] = (await (ctx.tx as NodePgDatabase).execute<{ maintenance: boolean | null }>(sql`select maintenance from settings where organization_id = ${ctx.tenant}`)).rows
154
+ if (held?.maintenance === true) {
155
+ throw invalid({ message: 'The portal is closed for maintenance, so no seat changes from it.', key: 'error.invalid.portal_closed', params: { kayan: 'enrollments' } })
156
+ }
157
+ return ctx
158
+ }
159
+ // end add-on: settings
160
+
161
+ export const enrollments = defineKayan({
162
+ name: 'enrollments',
163
+ access: {
164
+ read: ownSeats,
165
+ create: (args: { user: unknown; data?: unknown }) => !namesAnother(args),
166
+ update: (args: { user: unknown; data?: unknown }) => (namesAnother(args) ? false : ownSeats(args)),
167
+ delete: ownSeats,
168
+ },
169
+ // What a form says where a second seat of one student in one session is
170
+ // refused, over the shared words for a unique value taken.
171
+ messages: {
172
+ conflict: { unique: { en: 'This student already holds a seat in this session.', ar: 'لهذا الطالب مقعد في هذه الحصة بالفعل.' } },
173
+ },
174
+ // A student holds one seat in a session, which is one organization's, so the
175
+ // organization needs no column in the key. The mark is in it with its nulls
176
+ // not distinct, so a withdrawn seat makes room for a new one while two
177
+ // standing seats collide.
178
+ indexes: [{ fields: ['session', 'student', 'deletedAt'], unique: true, nullsNotDistinct: true }],
179
+ fields: ({ defaults }) => ({
180
+ ...defaults,
181
+ organization,
182
+ // Read as the session's course and its start on every screen, where a
183
+ // session alone is titled by its start.
184
+ //
185
+ // Both are required, so no write makes a seat for nobody or in nothing, and
186
+ // a session or a person that holds one is not deleted under it: a required
187
+ // relationship restricts.
188
+ session: {
189
+ kind: 'relationship', label: { en: 'Session', ar: 'الحصة' }, relationTo: () => sessions,
190
+ filterOptions: openSessions,
191
+ rules: { required: true },
192
+ targets: { dashboard: { cell: sessionCell }, portal: { cell: sessionCell }, backoffice: { cell: sessionCell } },
193
+ },
194
+ // A create that names no student enrolls the person making it: the runner's
195
+ // defaults step fills it from the actor, and a field with a default is one a
196
+ // create may leave out, at the HTTP boundary too. A role without the column's
197
+ // key is refused the filled value. A student holds it, so its own enrollment
198
+ // names itself, and `namesAnother` refuses it any other person.
199
+ student: {
200
+ kind: 'relationship', label: { en: 'Student', ar: 'الطالب' }, relationTo: 'user',
201
+ filterOptions: students,
202
+ rules: { required: true, defaultValue: actor.id },
203
+ },
204
+ /**
205
+ * The field the language names as its soft-delete mark, so a student who
206
+ * leaves a session is a row this kayan keeps rather than one it removes: an
207
+ * enrollment is the record that the student was there. It is the one kayan
208
+ * of this template that takes the mark. Nothing points at an enrollment, so
209
+ * no child outlives it, and nothing refers to it through a `restrict`
210
+ * foreign key that a delete would have to release. Nobody supplies the
211
+ * mark: the runner writes it at the delete.
212
+ *
213
+ * `session` and `student` are required, so each restricts, and a session or
214
+ * a person a marked enrollment still names is not deleted under it.
215
+ */
216
+ deletedAt: {
217
+ kind: 'datetime',
218
+ label: { en: 'Deleted at', ar: 'تاريخ الحذف' },
219
+ rules: { providedBy: { create: 'never', update: 'never' } },
220
+ },
221
+ }),
222
+ targets: {
223
+ // Every organization's rows on the backoffice, read-only, and each row's
224
+ // record, which only reads. A record is titled by its student, a person the
225
+ // backoffice reads, where a session is titled only by its start.
226
+ backoffice: {
227
+ useAsTitle: 'student',
228
+ pages: {
229
+ ...backofficeList('/enrollments', { columns: ['session', 'student', 'organization', 'createdAt'], filters: ['organization', 'session', 'student'], sort: '-createdAt' }),
230
+ ...backofficeRecord({
231
+ path: '/enrollments/:id',
232
+ main: [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student', 'deletedAt'] }],
233
+ }),
234
+ },
235
+ },
236
+ service: {
237
+ operations: {
238
+ ...defaultOperations,
239
+ create: { ...defaultOperations.create, steps: ({ steps }) => [
240
+ // add-on: settings
241
+ portalOpen,
242
+ // end add-on: settings
243
+ takesSeat,
244
+ steps.insert,
245
+ ] },
246
+ update: { ...defaultOperations.update, steps: ({ steps }) => [
247
+ // add-on: settings
248
+ portalOpen,
249
+ // end add-on: settings
250
+ takesSeat,
251
+ steps.update,
252
+ ] },
253
+ delete: { ...defaultOperations.delete, steps: ({ steps }) => [
254
+ // add-on: settings
255
+ portalOpen,
256
+ // end add-on: settings
257
+ leavesBeforeStart,
258
+ steps.remove,
259
+ ] },
260
+ },
261
+ },
262
+ // Titled by the student on the dashboard, as on the backoffice, so a tab and a
263
+ // heading tell one seat from another; on the portal, where the student is most
264
+ // often the viewer, by the session.
265
+ dashboard: {
266
+ useAsTitle: 'student',
267
+ description: {
268
+ en: 'Which student holds a seat in which session.',
269
+ ar: 'أي طالب يشغل مقعدا في أي حصة.',
270
+ },
271
+ pages: {
272
+ // The same table every kayan's list is. Searched by the student's name,
273
+ // the one column of a person a tenant reads, and filtered by session,
274
+ // student and when the student enrolled, as the course list is.
275
+ list: { path: '/enrollments', page: appPage([{ layout: 'table', main: [{ list: {
276
+ columns: ['session', 'student', 'createdAt'],
277
+ filters: ['session', 'student', 'createdAt'],
278
+ search: ['student.name'],
279
+ } }] }]) },
280
+ create: { path: '/enrollments/new', page: appPage([{ layout: 'record', main: [{ form: enrollment }] }]) },
281
+ // The editor layout, and the one timestamp worth reading here, named and
282
+ // read-only: when the student enrolled. Not the organization, which on the
283
+ // dashboard is always the active one.
284
+ edit: {
285
+ path: '/enrollments/:id',
286
+ page: appPage([{
287
+ layout: 'record',
288
+ main: [{ form: enrollment }],
289
+ side: [{ section: 'record', label: { en: 'Record', ar: 'السجل' }, fields: [{ field: 'createdAt', readOnly: true }] }, activity],
290
+ danger: danger(),
291
+ }]),
292
+ },
293
+ },
294
+ },
295
+ portal: {
296
+ useAsTitle: 'session',
297
+ pages: {
298
+ // The viewer's seats, upcoming first, drawn whole by the courses feature's portal.
299
+ list: { path: '/enrollments', component: portalUi('enrollments.tsx#Enrollments') },
300
+ // The list's New enrollment, which only staff are offered, with the
301
+ // student as the dashboard draws it, so an instructor picks whom it
302
+ // enrolls. A student who opens it by its address and names another is
303
+ // refused by `namesAnother`; one who names nobody enrolls itself.
304
+ create: { path: '/enrollments/new', page: portalPage([{ form: ['session', 'student'] }]) },
305
+ edit: { path: '/enrollments/:id', page: portalPage([{ form: ['session'] }]) },
306
+ },
307
+ },
308
+ },
309
+ })
@@ -1,4 +1,5 @@
1
- // What an enrollment's session column draws: the session's course by its title, then when it starts, which tells two sessions of one course apart.
1
+ // What an enrollment's session column draws: the session's course by its title, then when it starts, which tells two sessions of one course apart,
2
+ // then where and how it meets.
2
3
  // A session is titled by its start and a title is one field, so the course is a second read.
3
4
  //
4
5
  // The list's own `useRelationshipTitles` keeps only each session's title, the start as text, and not the course it points at, so this cell reads them again:
@@ -9,8 +10,10 @@ import { use } from 'react'
9
10
  import { useQuery } from '@tanstack/react-query'
10
11
  import type { CellProps, FetchClient, Row } from 'mithaq/admin'
11
12
  import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
12
- import { formatDate, noValue, refusalKeyOf, type FoundPage } from '@/components/mithaq/controls.tsx'
13
- import { findByIds, nameOfRow, titleFieldOf } from '@/components/mithaq/relationship-control.tsx'
13
+ import type { FoundPage } from 'mithaq-admin/control-props.ts'
14
+ import { formatDate, noValue } from 'mithaq-admin/format.tsx'
15
+ import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
16
+ import { findByIds, nameOfRow, titleFieldOf } from 'mithaq-admin/relationship-control.tsx'
14
17
 
15
18
  /** A refusal is the viewer's access answering, so it reads as no rows; anything else is a failure, and throws for the query to try again. */
16
19
  const unread = (thrown: unknown): never[] => {
@@ -36,7 +39,7 @@ const sessionsOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids:
36
39
  export function SessionCell({ field, value, row }: CellProps) {
37
40
  const client = useClient()
38
41
  const { locale, t } = useLocale()
39
- const { kayan: { courses }, maxLimit } = useScreen()
42
+ const { kayan: { courses, sessions: sessionKayan }, maxLimit } = useScreen()
40
43
  // A list's page, or the one row a record page draws.
41
44
  // Sorted, so the same rows in another order are the same query.
42
45
  const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (typeof one[field.name] === 'string' ? [one[field.name] as string] : [])))].sort()
@@ -45,12 +48,18 @@ export function SessionCell({ field, value, row }: CellProps) {
45
48
  const session = sessions?.get(value)
46
49
  const when = formatDate({ value: session?.startsAt, locale })
47
50
  const course = session?.course ? nameOfRow({ row: session.course as Row, title: titleFieldOf(courses), locale }) : undefined
51
+ // Where and how it meets, each by its option's label in the viewer's locale, where the viewer reads it.
52
+ const labelOf = (name: string) => {
53
+ const option = sessionKayan?.fields.find((one) => one.name === name)?.options?.find((one) => one.value === session?.[name])
54
+ return option?.label[locale]
55
+ }
56
+ const place = [labelOf('venue'), labelOf('format')].filter(Boolean).join(' · ')
48
57
  // Never the id the column holds, as the relationship cell draws none:
49
58
  // loading until the read answers, the server's render among it, a dash
50
- // where it failed, and a row the viewer cannot see for a session it may
51
- // not read or one of which it may read neither the course nor the start.
59
+ // where it failed, and not available for a session it may not read or one
60
+ // of which it may read neither the course nor the start.
52
61
  if (!when && !course) {
53
- if (sessions) return t('admin.cannotSee', 'You can\'t see this')
62
+ if (sessions) return t('admin.notAvailable', 'Not available')
54
63
  return isError ? '—' : t('admin.loading', 'Loading…')
55
64
  }
56
65
  // Two lines, the course over its start, so a narrow column wraps each
@@ -60,6 +69,7 @@ export function SessionCell({ field, value, row }: CellProps) {
60
69
  {course ? <span className="font-medium">{course}</span> : null}
61
70
  {/* One unbroken run, so a narrow column breaks between the course and the date and never inside the date. */}
62
71
  {when ? <span className="text-muted-foreground text-xs"><bdi className="whitespace-nowrap">{when}</bdi></span> : null}
72
+ {place ? <span className="text-muted-foreground text-xs">{place}</span> : null}
63
73
  </span>
64
74
  )
65
75
  }
@@ -10,9 +10,9 @@ import type { ReactNode } from 'react'
10
10
  import { useQuery } from '@tanstack/react-query'
11
11
  import type { ProviderProps, Row } from 'mithaq/admin'
12
12
  import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
13
- import { formatDate } from '@/components/mithaq/controls.tsx'
14
- import { nameOfRow, RowLabelContext, titleFieldOf, type UseRowLabel } from '@/components/mithaq/relationship-control.tsx'
15
- import { coursesOf } from '../enrollments/ui/session-cell.tsx'
13
+ import { formatDate } from 'mithaq-admin/format.tsx'
14
+ import { nameOfRow, RowLabelContext, titleFieldOf, type UseRowLabel } from 'mithaq-admin/relationship-control.tsx'
15
+ import { coursesOf } from './session-cell.tsx'
16
16
 
17
17
  /** The session's course and its start, or nothing for any other kayan and for a session whose course is not read yet. */
18
18
  export const useSessionLabel: UseRowLabel = (target, rows) => {
@@ -0,0 +1,8 @@
1
+ import { courses } from './courses/courses.ts'
2
+ import { enrollments } from './enrollments/enrollments.ts'
3
+ import { sessions } from './sessions/sessions.ts'
4
+
5
+ export const kayan = [courses, sessions, enrollments] as const
6
+
7
+ // The images a course's reader reads as its cover, which the asas hands the media feature.
8
+ export { coverReads } from './courses/cover-reads.ts'
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@asas/courses",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts",
8
+ "./domain/*": "./domain/*",
9
+ "./portal/*": "./portal/*"
10
+ },
11
+ "dependencies": {
12
+ "@asas/mithaq": "*",
13
+ "@fontsource-variable/bricolage-grotesque": "^5.3.0",
14
+ "mithaq": "*",
15
+ "mithaq-admin": "*",
16
+ "zod": "^4.6.5"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^19.3.0",
20
+ "lucide-react": "^1.16.0",
21
+ "react": "^19.3.0",
22
+ "@tanstack/react-query": "^5.104.0"
23
+ }
24
+ }
@@ -0,0 +1,86 @@
1
+ // The portal's catalogue: the courses on offer as cards, searched by title and summary and narrowed by tag, each
2
+ // card opening the course's page.
3
+ import { useState } from 'react'
4
+ import { Search } from 'lucide-react'
5
+ import { useQuery } from '@tanstack/react-query'
6
+ import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
7
+ import { Input } from '@/components/ui/input'
8
+ import { Toggle } from '@/components/ui/toggle'
9
+ import { PortalShell, display } from './shell.tsx'
10
+ import { CardsLoading, CourseCard, ReadFailed } from './parts.tsx'
11
+ import { inLocale } from './rules.ts'
12
+ import { catalogueOf } from './reads.ts'
13
+
14
+ /** Drawn whole as the courses' list page, so it reads the list itself. */
15
+ export function Catalogue() {
16
+ const { locale, t } = useLocale()
17
+ const client = useClient()
18
+ const screen = useScreen()
19
+ const read = useQuery({ queryKey: ['portal-catalogue'], queryFn: () => catalogueOf({ client, maxLimit: screen.maxLimit, now: Date.now() }), staleTime: 30_000 })
20
+ const [term, setTerm] = useState('')
21
+ const [tag, setTag] = useState<string>()
22
+ const found = read.data
23
+ const asked = term.trim().toLocaleLowerCase(locale)
24
+ const shown = found?.courses.filter((course) =>
25
+ (!tag || (Array.isArray(course.tags) && course.tags.includes(tag)))
26
+ && (!asked || [course.title, course.summary].some((one) => typeof one === 'string' && one.toLocaleLowerCase(locale).includes(asked))))
27
+ const title = t('courses.plural', 'Courses')
28
+ return (
29
+ <PortalShell
30
+ regions={{
31
+ header: <h1 className={`${display} text-3xl`}>{title}</h1>,
32
+ main: (
33
+ <div className="grid gap-5">
34
+ <div className="grid gap-3">
35
+ <label className="border-input bg-card focus-within:ring-ring flex h-12 items-center gap-2.5 rounded-full border px-4 focus-within:ring-2">
36
+ <Search aria-hidden className="text-muted-foreground size-4.5 shrink-0" />
37
+ <span className="sr-only">{t('portal.search', 'Search courses')}</span>
38
+ <Input
39
+ type="search"
40
+ value={term}
41
+ onChange={(event) => setTerm(event.target.value)}
42
+ placeholder={t('portal.search', 'Search courses')}
43
+ className="h-auto border-0 bg-transparent p-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent"
44
+ />
45
+ </label>
46
+ {found?.tags.size ? (
47
+ <div role="group" aria-label={t('portal.byTag', 'Filter by tag')} className="flex flex-wrap gap-2">
48
+ <Toggle variant="outline" pressed={!tag} onPressedChange={() => setTag(undefined)} className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4">
49
+ {t('portal.allTags', 'All')}
50
+ </Toggle>
51
+ {[...found.tags.values()].map((one) => (
52
+ <Toggle
53
+ key={String(one.id)}
54
+ variant="outline"
55
+ pressed={tag === one.id}
56
+ onPressedChange={(pressed) => setTag(pressed ? String(one.id) : undefined)}
57
+ className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4"
58
+ >
59
+ {inLocale(one.name, locale)}
60
+ </Toggle>
61
+ ))}
62
+ </div>
63
+ ) : null}
64
+ </div>
65
+ {read.isError ? <ReadFailed retry={() => void read.refetch()} /> : !found || !shown ? <CardsLoading /> : shown.length ? (
66
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-5">
67
+ {shown.map((course) => (
68
+ <CourseCard
69
+ key={String(course.id)}
70
+ course={course}
71
+ cover={found.covers.get(String(course.cover))}
72
+ next={found.next.get(String(course.id))}
73
+ tags={found.tags}
74
+ href={`${screen.path}/courses/${encodeURIComponent(String(course.id))}`}
75
+ />
76
+ ))}
77
+ </div>
78
+ ) : (
79
+ <p className="text-muted-foreground text-sm">{found.courses.length ? t('portal.noMatch', 'No course matches.') : t('courses.noneOnOffer', 'No courses on offer yet.')}</p>
80
+ )}
81
+ </div>
82
+ ),
83
+ }}
84
+ />
85
+ )
86
+ }