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
@@ -1,1024 +0,0 @@
1
- import { Fragment, use, useMemo, useRef, useState, type ComponentType, type Key, type ReactElement, type ReactNode, type RefObject } from 'react'
2
- import { ChevronDown, XIcon } from 'lucide-react'
3
- import { kindOf, matchesWhere, regionsIn } from 'mithaq/admin'
4
- import { ScreenContext, useLocale, useScreen } from 'mithaq/admin/react'
5
- import type {
6
- ButtonClient,
7
- ButtonProps,
8
- CellProps as ScreenCellProps,
9
- ClientField,
10
- ClientKayan,
11
- ComponentProps,
12
- ControlProps as ScreenControlProps,
13
- EmptyProps,
14
- MenuProps,
15
- FrameClient,
16
- LayoutProps,
17
- LeafClient,
18
- NodeClient,
19
- NodeKind,
20
- NodeOf,
21
- PageClient,
22
- PageProps,
23
- ProviderProps,
24
- Row,
25
- RegionsClient,
26
- ShellProps,
27
- Translate,
28
- } from 'mithaq/admin'
29
- import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
30
- import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from '@/components/ui/accordion'
31
- import { Badge } from '@/components/ui/badge'
32
- import { Button } from '@/components/ui/button'
33
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
34
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
35
- import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog'
36
- import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer'
37
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
38
- import { ScrollArea } from '@/components/ui/scroll-area'
39
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
40
- import { Separator } from '@/components/ui/separator'
41
- import { Sheet, SheetClose, SheetContent, SheetTitle } from '@/components/ui/sheet'
42
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
43
- import {
44
- besideLabel,
45
- controlFor,
46
- controls,
47
- descriptionFor,
48
- labelFor,
49
- newTitleOf,
50
- placeholderFor,
51
- titleOf,
52
- type ControlProps,
53
- type Copy,
54
- type FindApi,
55
- type FoundPage,
56
- } from './controls.tsx'
57
- import { FieldShell, describedBy } from './field-shell.tsx'
58
- import { idsOf, nameOfRow } from './relationship-control.tsx'
59
- import { titleKey, useRelationshipTitles } from './relationship-cell.tsx'
60
-
61
- /**
62
- * What each place a screen names a component hands it, from `mithaq/admin`, with a React node where
63
- * the props carry one. Everything else a component reads through the hooks `mithaq/admin/react`
64
- * exports.
65
- */
66
- export type Places = {
67
- provider: ComponentType<ProviderProps<ReactNode>>
68
- shell: ComponentType<ShellProps<readonly string[], ReactNode>>
69
- page: ComponentType<PageProps>
70
- layout: ComponentType<LayoutProps<readonly string[], ReactNode>>
71
- component: ComponentType<ComponentProps>
72
- control: ComponentType<ScreenControlProps>
73
- cell: ComponentType<ScreenCellProps>
74
- empty: ComponentType<EmptyProps>
75
- menu: ComponentType<MenuProps<ReactNode>>
76
- button: ComponentType<ButtonProps>
77
- }
78
-
79
- /** The components a screen names by path, keyed by the path `screen.json` prints. */
80
- export type Refs = Readonly<Record<string, ComponentType<never>>>
81
-
82
- /** A kayan's page declared as one tree, rather than drawn whole by one component. */
83
- export type TreePage = Extract<PageClient, { page: FrameClient }>
84
-
85
- /** What a form or a list draws a screen's page with, beside the adapted kayan. */
86
- export type ScreenPage = {
87
- refs: Refs
88
- /** The screen's own kayan, which is what a component named by path is handed. */
89
- kayan: ClientKayan
90
- page: TreePage
91
- }
92
-
93
- /** A component the page names by path, typed for the place it sits in. */
94
- export const refOf = <Place extends keyof Places>({
95
- screen,
96
- place: _place,
97
- path,
98
- }: {
99
- screen: Pick<ScreenPage, 'refs'> | undefined
100
- place: Place
101
- path: string | undefined
102
- }) =>
103
- (screen && path && Object.hasOwn(screen.refs, path) ? screen.refs[path] : undefined) as Places[Place] | undefined
104
-
105
- export type ListNode = NodeOf<'list'>['list']
106
- /** A node of the kind it is, once `kindOf` has said which. */
107
- const ofKind = <Kind extends NodeKind,>(node: NodeClient, kind: Kind) => (kindOf(node) === kind ? (node as NodeOf<Kind>) : undefined)
108
- /** What a node holds, off its kind's entry in the one map. */
109
- const inside = (node: NodeClient): readonly NodeClient[] => (nodeMap[kindOf(node)!].children as (node: NodeClient) => readonly NodeClient[])(node)
110
- /** The first node some nodes hold at any depth that `is` picks. */
111
- const first = <Found,>(nodes: readonly NodeClient[], is: (node: NodeClient) => Found | undefined): Found | undefined => {
112
- for (const node of nodes) {
113
- const found = is(node) ?? first(inside(node), is)
114
- if (found !== undefined) return found
115
- }
116
- return undefined
117
- }
118
- /** The first list node of some nodes, wherever it sits, that lists the page's own kayan. */
119
- export const listIn = (nodes: readonly NodeClient[]): ListNode | undefined =>
120
- first(nodes, (node) => {
121
- const list = ofKind(node, 'list')?.list
122
- return list && !list.of ? list : undefined
123
- })
124
- /** Every list node some nodes hold that lists another kayan scoped to the page's row, wherever each sits. */
125
- export const relatedIn = (nodes: readonly NodeClient[], scope?: Row): ListNode[] =>
126
- nodes.flatMap((node) => {
127
- // A node its `when` hides is not drawn, so nothing under it is read either: the read would only be
128
- // refused. A tab that hides every node it holds is the tab strip's to leave out, and its nodes are
129
- // each hidden here.
130
- if (scope && !shown(node, scope)) return []
131
- const list = ofKind(node, 'list')?.list
132
- return list ? (list.of ? [list] : []) : relatedIn(inside(node), scope)
133
- })
134
- /** The first form node, wherever it sits: a page that holds one is drawn by a form. */
135
- const formNodeIn = (nodes: readonly NodeClient[]): NodeOf<'form'> | undefined => first(nodes, (node) => ofKind(node, 'form'))
136
- /** The first form node's nodes, wherever it sits. */
137
- export const formIn = (nodes: readonly NodeClient[]): NodeClient[] | undefined => formNodeIn(nodes)?.form
138
- /**
139
- * Whether a page is drawn as the row's values rather than a form: it holds no form, or the viewer may update the row
140
- * nowhere, where boxes refusing every keystroke would only pretend. A project component's form decides itself.
141
- */
142
- export const readsAsValues = ({
143
- page,
144
- value,
145
- operations,
146
- }: {
147
- page: TreePage
148
- value: Row | null | undefined
149
- operations: Readonly<Record<string, boolean>>
150
- }): boolean => {
151
- const form = formNodeIn(nodesOf(page))
152
- return !form || (!!value && operations.update !== true && !form.component)
153
- }
154
- /** Whether some nodes draw a node of this kind over the scope: the node and every node holding it must be shown. */
155
- export const drawsKind = ({ nodes, kind, scope }: { nodes: readonly NodeClient[]; kind: NodeKind; scope: Row }): boolean =>
156
- nodes.some((node) => shown(node, scope) && (kindOf(node) === kind || drawsKind({ nodes: inside(node), kind, scope })))
157
- /** Every node of a page's tree, region by region, from its shell down. */
158
- export const nodesOf = (page: TreePage): NodeClient[] => regionsIn(page.page).flatMap(([, nodes]) => nodes)
159
- /** The list node a screen's page draws, which is what a list page holds exactly one of. */
160
- export const listOf = (screen: { page: TreePage } | undefined): ListNode | undefined => screen && listIn(nodesOf(screen.page))
161
-
162
- /**
163
- * Whether a nav entry is the page the viewer is on: its kayan is the page's own, on every page of that
164
- * kayan. Known on the server, so the first paint marks it.
165
- */
166
- export const currentOf = (entry: { kayan?: string }, kayan: string | undefined): 'page' | undefined =>
167
- entry.kayan !== undefined && entry.kayan === kayan ? 'page' : undefined
168
-
169
- /**
170
- * What one row is called by its kayan's own title field, in the viewer's
171
- * locale, or nothing where the kayan declares none, the row holds none, or
172
- * the title is a relationship that has to be read first; never the row's id.
173
- */
174
- export const rowTitle = ({ kayan, row, locale }: { kayan: ClientKayan; row: Row; locale: string }): string | undefined => {
175
- const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')
176
- const held = field ? row[field.name] : undefined
177
- if (!field || field.kind === 'relationship' || held === null || held === undefined || held === '') return undefined
178
- const name = nameOfRow({ row, title: field.name, locale, kind: field.kind })
179
- return name && name !== String(row.id ?? '') ? name : undefined
180
- }
181
-
182
- /**
183
- * Whether a row's title is empty: the kayan is titled by a field the reader may read, and its value (in the viewer's
184
- * locale if localized) is nothing or blank. A draft may hold no title, and a snapshot of one may lack the key.
185
- */
186
- export const isUntitled = ({ kayan, row, locale }: { kayan: ClientKayan; row: Row; locale: string }): boolean => {
187
- const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')
188
- if (!field || field.kind === 'relationship') return false
189
- const held = row[field.name]
190
- const text = held !== null && typeof held === 'object' && !(held instanceof Date) ? (held as Row)[locale] : held
191
- return text === null || text === undefined || (typeof text === 'string' && text.trim() === '')
192
- }
193
-
194
- /**
195
- * What a button's dialog says it runs on: the row's title (or "this row"), in the operation's `.warning` sentence.
196
- * The title is isolated with U+2068/U+2069 so an email or digits in an Arabic sentence keep their order.
197
- */
198
- export const rowWarning = ({ kayan, row, operation, copy: { t, locale } }: { kayan: ClientKayan; row: Row; operation?: string; copy: Copy }): string => {
199
- const name = rowTitle({ kayan, row, locale })
200
- if (!name) return t('admin.rowWarning', 'This applies to this row.') ?? 'This applies to this row.'
201
- const sentence = (operation && t(`${kayan.name}.operations.${operation}.warning`)) || (t('admin.rowNamedWarning', 'This applies to {name}.') ?? 'This applies to {name}.')
202
- return sentence.replace('{name}', () => `\u2068${name}\u2069`)
203
- }
204
-
205
- /**
206
- * A page's title read across its titling relationship: the related row's title, or the kind's name until it answers,
207
- * on every server render, and for a row the viewer may not read.
208
- */
209
- function RelatedTitle({ Heading, field, row, locale, fallback }: { Heading: 'h1' | 'h2'; field: ClientField; row: Row; locale: string; fallback: string }): ReactElement {
210
- const state = use(ScreenContext)
211
- const target = field.relationTo ?? ''
212
- const api = useMemo<FindApi | undefined>(() => (state ? { [target]: { find: (input) => state.client({ kayan: target, operation: 'find', input }) as Promise<FoundPage> } } : undefined), [state?.client, target])
213
- const titles = useRelationshipTitles({ fields: [field], docs: [row], context: { api, client: Object.values(state?.screen.kayan ?? {}), locale } })
214
- const id = idsOf(row[field.name])[0]
215
- const name = id === undefined ? undefined : titles.get(titleKey(target, id))
216
- const title = typeof name === 'string' && name !== id ? name : fallback
217
- return <Heading data-node="title" title={title} className="text-2xl font-bold tracking-tight">{title}</Heading>
218
- }
219
-
220
- /** The screen's nav as the host provides it: one link per entry under the screen's path. */
221
- export function NavLinks(): ReactElement {
222
- const screen = useScreen()
223
- const copy = useLocale()
224
- const current = use(ScreenContext)?.kayan?.name
225
- return (
226
- <ul className="flex flex-col gap-1">
227
- {screen.nav.map((entry) => (
228
- <li key={`${entry.page}${entry.path}`}>
229
- <a href={`${screen.path}${entry.path}`} aria-current={currentOf(entry, current)} className="text-sm">
230
- {entry.label?.[copy.locale] ?? titleOf({ kayan: { name: entry.kayan ?? entry.page }, copy, many: !screen.kayan[entry.kayan ?? '']?.single })}
231
- </a>
232
- </li>
233
- ))}
234
- </ul>
235
- )
236
- }
237
-
238
- /**
239
- * A button's `confirm`, asked in the app's own alert dialog in the page's locale rather than the
240
- * browser's. `ask` answers whether the person said yes, at once for a button that carries no
241
- * confirm, and `dialog` is the question, drawn beside the button that asks it.
242
- */
243
- export const useConfirm = ({
244
- confirm,
245
- confirmLabel,
246
- description,
247
- copy: { t, locale },
248
- }: {
249
- confirm: ButtonClient['confirm']
250
- confirmLabel: string
251
- /** What the caller says the button runs on, such as a bulk's count of ticked rows, before the confirm's own body. */
252
- description?: string
253
- copy: Copy
254
- }) => {
255
- const [answer, setAnswer] = useState<(yes: boolean) => void>()
256
- const settle = (yes: boolean) => {
257
- answer?.(yes)
258
- setAnswer(undefined)
259
- }
260
- const heading = confirm?.title[locale]
261
- const body = [description, confirm?.body[locale]].filter(Boolean).join(' ')
262
- const onConfirm = () => settle(true)
263
- const onCancel = () => settle(false)
264
- const ask = () => (confirm ? new Promise<boolean>((resolve) => setAnswer(() => resolve)) : Promise.resolve(true))
265
- const dialog = confirm ? (
266
- <AlertDialog open={answer !== undefined} onOpenChange={(open: boolean) => (open ? undefined : onCancel())}>
267
- <AlertDialogContent>
268
- <AlertDialogHeader>
269
- <AlertDialogTitle>{heading}</AlertDialogTitle>
270
- <AlertDialogDescription>{body}</AlertDialogDescription>
271
- </AlertDialogHeader>
272
- <AlertDialogFooter className="max-sm:[&_button]:h-11">
273
- <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
274
- <AlertDialogAction data-button="confirm-operation" onClick={onConfirm}>
275
- {confirmLabel}
276
- </AlertDialogAction>
277
- </AlertDialogFooter>
278
- </AlertDialogContent>
279
- </AlertDialog>
280
- ) : null
281
- return { ask, dialog }
282
- }
283
-
284
- /**
285
- * A project's own button component, handed a run that asks the button's
286
- * confirm first, so no component can run an operation past its question.
287
- */
288
- export function OwnButton({
289
- Own,
290
- busy,
291
- call,
292
- copy,
293
- operation,
294
- }: {
295
- Own: Places['button']
296
- busy: boolean
297
- call: (input: Row) => Promise<void>
298
- copy: Copy
299
- operation: Pick<PageButton, 'name' | 'label' | 'confirm'>
300
- }): ReactElement {
301
- const { ask, dialog } = useConfirm({ confirm: operation.confirm, confirmLabel: operation.label[copy.locale] ?? operation.name, copy })
302
- return (
303
- <>
304
- <Own operation={operation.name} busy={busy} run={async (input: Row) => void ((await ask()) && (await call(input)))} />
305
- {dialog}
306
- </>
307
- )
308
- }
309
-
310
- /**
311
- * A button that runs its operation where its node sits, after its confirm.
312
- * It is what a node draws when it asks for no values: a form draws a dialog
313
- * for an operation that does.
314
- */
315
- export function NodeButton({ node, copy, busy, run, description }: { node: Pick<ButtonClient, 'label' | 'confirm'>; copy: Copy; busy: boolean; run: () => void; description?: string }): ReactElement {
316
- const label = node.label[copy.locale]
317
- const { ask, dialog } = useConfirm({ confirm: node.confirm, confirmLabel: label ?? '', description, copy })
318
- return (
319
- <>
320
- <Button type="button" variant="outline" className="max-sm:h-11" disabled={busy} onClick={() => void ask().then((yes) => yes && run())}>
321
- {label}
322
- </Button>
323
- {dialog}
324
- </>
325
- )
326
- }
327
-
328
- /**
329
- * A bulk update's box for a yes-or-no field, which has three answers where a checkbox has two: leave
330
- * each row's value as it is, set it, or clear it. The two values read in the field's own words, and
331
- * leaving it as it is sends nothing.
332
- */
333
- export function BulkChoice({
334
- copy: { t },
335
- describedBy,
336
- error,
337
- field,
338
- id,
339
- kayan,
340
- onChange,
341
- readOnly,
342
- value,
343
- }: {
344
- copy: Copy
345
- describedBy?: string
346
- /** A refusal of this box, which marks the trigger invalid as every control marks itself. */
347
- error?: string
348
- field: ClientField
349
- id: string
350
- kayan: ClientKayan
351
- onChange: (value: boolean | undefined) => void
352
- readOnly: boolean
353
- value: unknown
354
- }): ReactElement {
355
- const word = (held: boolean) => t(`${kayan.name}.${field.name}.${held}`) ?? (held ? t('admin.yes', 'Yes') : t('admin.no', 'No')) ?? String(held)
356
- const keep = t('admin.leaveAsIs', 'Leave as is') ?? 'Leave as is'
357
- const current = typeof value === 'boolean' ? String(value) : 'keep'
358
- return (
359
- <Select value={current} onValueChange={(next: string | null) => onChange(next === 'true' ? true : next === 'false' ? false : undefined)} disabled={readOnly}>
360
- <SelectTrigger id={id} className="w-full" aria-describedby={describedBy} aria-invalid={error ? true : undefined}>
361
- <SelectValue>{current === 'keep' ? keep : word(current === 'true')}</SelectValue>
362
- </SelectTrigger>
363
- <SelectContent>
364
- <SelectItem value="keep">{keep}</SelectItem>
365
- <SelectItem value="true">{word(true)}</SelectItem>
366
- <SelectItem value="false">{word(false)}</SelectItem>
367
- </SelectContent>
368
- </Select>
369
- )
370
- }
371
-
372
- /** One button a page draws where its node sits: the operation, and what the node says about it. */
373
- export type PageButton = ClientKayan['operations'][number] & Pick<ButtonClient, 'label' | 'component' | 'confirm'>
374
-
375
- /**
376
- * The button a node draws for this actor, when the kayan declares its
377
- * operation and the report allows it by its own name, with the fields its
378
- * dialog asks for cut to the ones the node names.
379
- */
380
- export const pageButtonOf = ({
381
- kayan,
382
- node,
383
- allowed,
384
- }: {
385
- kayan: ClientKayan
386
- node: ButtonClient
387
- allowed: Readonly<Record<string, boolean>> | undefined
388
- }): PageButton | undefined => {
389
- const operation = kayan.operations.find((one) => one.name === node.button)
390
- if (!operation || allowed?.[operation.name] !== true) return undefined
391
- const fields = operation.fields?.filter((one) => !node.fields || node.fields.includes(one.name))
392
- return { ...operation, ...(fields && { fields }), label: node.label, ...(node.component && { component: node.component }), ...(node.confirm && { confirm: node.confirm }) }
393
- }
394
-
395
- /** A named group's label and description, resolved the way a field's are. */
396
- export const groupCopy = ({
397
- kayan,
398
- name,
399
- group,
400
- copy: { t, locale },
401
- }: {
402
- kayan: ClientKayan
403
- name: string
404
- group: { label?: Readonly<Record<string, string>>; description?: Readonly<Record<string, string>> }
405
- copy: Copy
406
- }) => ({
407
- label: (typeof group.label === 'object' ? group.label[locale] : undefined) ?? t(`${kayan.name}.layout.${name}`, name) ?? name,
408
- description:
409
- (typeof group.description === 'object' ? group.description[locale] : undefined)
410
- ?? t(`${kayan.name}.layout.${name}.description`),
411
- })
412
-
413
- /** One node of a screen's page. */
414
- export type Node = NodeClient
415
-
416
- /** Every field name a tree mentions, at any depth, of the leaves `keep` keeps. */
417
- export const leavesOf = (nodes: readonly Node[], keep: (leaf: LeafClient) => boolean = () => true): readonly string[] =>
418
- nodes.flatMap((node): readonly string[] => {
419
- const leaf = ofKind(node, 'field')
420
- return leaf ? (keep(leaf) ? [leaf.field] : []) : leavesOf(inside(node), keep)
421
- })
422
-
423
- /**
424
- * One tab strip. `keepMounted` renders every panel, hiding the inactive ones, so a field on a tab nobody opened
425
- * still holds its value; base-ui unmounts a hidden panel otherwise.
426
- */
427
- function TabGroup({
428
- line,
429
- tabs,
430
- }: {
431
- line: boolean
432
- tabs: readonly { key: string; label: string; description?: string; invalid: number; invalidLabel: string; body: ReactNode }[]
433
- }): ReactElement {
434
- return (
435
- <Tabs defaultValue={tabs[0]?.key} data-node="tabs" className={line ? 'gap-4' : 'overflow-hidden rounded-lg border'}>
436
- <TabsList variant={line ? 'line' : 'default'} className={line ? 'w-full justify-start max-sm:min-h-12' : 'm-1 max-sm:min-h-12'}>
437
- {tabs.map((tab) => (
438
- <TabsTrigger key={tab.key} value={tab.key} className={line ? 'flex-none max-sm:min-h-11' : 'max-sm:min-h-11'}>
439
- {tab.label}
440
- {/* How many of this tab's fields the refusal named, so a save refused over a field on a tab
441
- nobody opened says so on the tab. */}
442
- {tab.invalid > 0 ? (
443
- <Badge variant="destructive" aria-label={tab.invalidLabel} title={tab.invalidLabel} data-mithaq-invalid={tab.invalid}>
444
- {tab.invalid}
445
- </Badge>
446
- ) : null}
447
- </TabsTrigger>
448
- ))}
449
- </TabsList>
450
- {tabs.map((tab) => (
451
- <TabsContent key={tab.key} value={tab.key} keepMounted className={line ? 'flex flex-col gap-4' : 'flex flex-col gap-4 p-4'}>
452
- {tab.description ? <p className="text-sm text-muted-foreground">{tab.description}</p> : null}
453
- {tab.body}
454
- </TabsContent>
455
- ))}
456
- </Tabs>
457
- )
458
- }
459
-
460
- /** What a tree is drawn with: the form's own answer for each leaf and component, and the frame each layout is. */
461
- export type Draw = {
462
- kayan: ClientKayan
463
- copy: Copy
464
- /** The refusal per field, which a tab counts over the fields it holds. */
465
- issues: Readonly<Record<string, string>>
466
- control: (leaf: string | LeafClient) => ReactNode
467
- component: (path: string, index: number) => ReactNode
468
- /** A layout nested in a region, handed its own regions drawn. */
469
- layout: (args: { path: string; regions: Record<string, ReactNode>; index: number }) => ReactNode
470
- /** A list node, the page's own or one of another kayan, and the component that draws it in place of the registry's list. */
471
- list?: (args: { node: ListNode; index: number; component?: string }) => ReactNode
472
- /** A button node, which only what holds a row to run it over draws. */
473
- button?: (node: ButtonClient, index: number) => ReactNode
474
- /** The derived nav, which the host holds. */
475
- nav?: () => ReactNode
476
- /** What a form node draws around what it holds. */
477
- form?: (children: ReactNode) => ReactNode
478
- /** The form's controls, which only a form draws: where the row stands, its last save and its buttons. */
479
- controls?: () => ReactNode
480
- /**
481
- * What a node's `when` reads: the form's values by field name, the row the
482
- * page draws as `record` and the access report as `$user`, which `scopeOf`
483
- * puts together.
484
- */
485
- scope: Row
486
- /**
487
- * Whether the nodes drawn sit inside a card: a section, an accordion, a pill
488
- * tab strip, or the card a run of loose fields is drawn on. A tab strip reads
489
- * it to pick the pill strip or the line bar.
490
- */
491
- inCard?: boolean
492
- /**
493
- * Whether the page lists the kayan's own rows, where its title names the kayan by its plural:
494
- * "Courses" over the list, "Course" over one of them. A list of another kayan on a record page
495
- * leaves it the singular.
496
- */
497
- many?: boolean
498
- /**
499
- * Whether the page opens over its list in a dialog, a sheet or a drawer, where its title is an h2
500
- * so the list's h1 behind it stays the page's one.
501
- */
502
- framed?: boolean
503
- }
504
-
505
- /**
506
- * What a `when` reads, from the values a form holds, the row the page draws and the actor's access
507
- * report, and the kind of each field of the kayan, which a decimal is compared by. No `when` can name
508
- * `$kinds`: the build refuses a key that is no field and not `$user`.
509
- */
510
- export const scopeOf = ({ values, record, access, kayan }: { values: Row; record: Row | null | undefined; access: unknown; kayan: ClientKayan }): Row =>
511
- ({ ...values, record: record ?? null, $user: access ?? null, $kinds: Object.fromEntries(kayan.fields.map((field) => [field.name, field.kind])) })
512
-
513
- /**
514
- * Whether a node is drawn: it carries no `when`, or its `when` holds over the scope, a dotted key
515
- * read one segment at a time. It decides what is shown and nothing else: the server still refuses
516
- * what the actor may not do.
517
- */
518
- export const shown = (node: NodeClient, scope: Row) =>
519
- typeof node !== 'object' || !node.when
520
- || matchesWhere({
521
- where: node.when,
522
- valueOf: (key) => key.split('.').reduce<unknown>((value, part) => (value && typeof value === 'object' ? (value as Row)[part] : undefined), scope),
523
- // A field is named alone, as `record.<field>`, or with a locale after it.
524
- kindOf: (key) => {
525
- const [head, next] = key.split('.')
526
- return (scope.$kinds as Record<string, string> | undefined)?.[head === 'record' ? next! : head!]
527
- },
528
- })
529
-
530
- /**
531
- * Every kind of node, how it draws and what it holds: the one map every walk of a tree goes through.
532
- * TypeScript refuses it with a kind left out. The set is closed: a project draws its own thing with a
533
- * `component` node, and changes how a kind draws by editing its copy of this map.
534
- */
535
- export type NodeMap = {
536
- [Kind in NodeKind]: {
537
- /** The node drawn; `inRow` when it sits in a row, where a section takes its width. */
538
- draw: (args: { node: NodeOf<Kind>; draw: Draw; index: number; inRow: boolean }) => ReactNode
539
- /** The nodes it holds, which a walk descends into. */
540
- children: (node: NodeOf<Kind>) => readonly NodeClient[]
541
- }
542
- }
543
-
544
- const none = () => []
545
-
546
- /**
547
- * How each node kind is drawn. A list is drawn by the host that holds its rows, and by nothing where no host hands one.
548
- * Every node carries its kind as `data-node`, so a served page says what it drew.
549
- */
550
- export const nodeMap: NodeMap = {
551
- layout: {
552
- draw: ({ node, draw, index }) => <div key={`layout-${index}`} data-node="layout" className="contents">{draw.layout({ path: node.layout, regions: regionsOf(node, draw), index })}</div>,
553
- children: (node) => regionsIn(node).flatMap(([, nodes]) => nodes),
554
- },
555
- field: { draw: ({ node, draw }) => draw.control(node), children: none },
556
- form: {
557
- draw: ({ node, draw, index }) => {
558
- // A form node that names a component is drawn by it in place of its leaves, and the form still
559
- // holds, checks and saves the values.
560
- const held = node.component ? draw.component(node.component, index) : carded(node.form, draw)
561
- return <div key={`form-${index}`} data-node="form" className="flex flex-col gap-4">{draw.form ? draw.form(held) : held}</div>
562
- },
563
- children: (node) => node.form,
564
- },
565
- list: {
566
- draw: ({ node, draw, index }) => <div key={`list-${index}`} data-node="list" className="contents">{draw.list?.({ node: node.list, index, component: node.component })}</div>,
567
- children: none,
568
- },
569
- button: { draw: ({ node, draw, index }) => <div key={`button-${index}`} data-node="button" className="contents">{draw.button?.(node, index)}</div>, children: none },
570
- title: {
571
- // The whole title is its tooltip too, as a header may cut it short on a phone. The title is the row
572
- // as read, not the value being typed, so it changes once a save lands.
573
- draw: ({ draw: { kayan, copy, many, framed, form, scope }, index }) => {
574
- const Heading = framed ? 'h2' : 'h1'
575
- const record = scope.record as Row | null | undefined
576
- const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')
577
- // A global with no saved row yet is still its one page, so it keeps its name.
578
- const fallback = !many && form && !record && !kayan.single ? newTitleOf({ kayan, copy }) : titleOf({ kayan, copy, many })
579
- if (!many && record && field?.kind === 'relationship') return <RelatedTitle key={`title-${index}`} Heading={Heading} field={field} row={record} locale={copy.locale} fallback={fallback} />
580
- const untitled = !many && !!record && isUntitled({ kayan, row: record, locale: copy.locale })
581
- const title = untitled ? (copy.t('admin.untitled', 'Untitled') ?? 'Untitled') : (!many && record ? rowTitle({ kayan, row: record, locale: copy.locale }) : undefined) ?? fallback
582
- return <Heading key={`title-${index}`} data-node="title" title={title} className="text-2xl font-bold tracking-tight">{untitled ? <span className="text-muted-foreground">{title}</span> : title}</Heading>
583
- },
584
- children: none,
585
- },
586
- nav: {
587
- // Nothing in a frame: the list's own shell behind it already draws the nav.
588
- draw: ({ draw, index }) => !draw.framed && <nav key={`nav-${index}`} data-node="nav" aria-label={draw.copy.t('admin.navigation', 'Navigation')}>{draw.nav?.()}</nav>,
589
- children: none,
590
- },
591
- // Drawn only by a form, which hands its controls; off a form page the node draws nothing.
592
- controls: {
593
- draw: ({ draw, index }) => draw.controls && <div key={`controls-${index}`} data-node="controls" className="contents">{draw.controls()}</div>,
594
- children: none,
595
- },
596
- component: {
597
- draw: ({ node, draw, index }) => <div key={`component-${index}`} data-node="component" className="contents">{draw.component(node.component, index)}</div>,
598
- children: none,
599
- },
600
- row: {
601
- draw: ({ node, draw, index }) =>
602
- node.resizable ? (
603
- <ResizablePanelGroup key={`row-${index}`} data-node="row" orientation="horizontal" className="rounded-lg border">
604
- {drawNodes({ nodes: node.row, draw }).flatMap((child, at) => [
605
- ...(at ? [<ResizableHandle key={`handle-${at}`} withHandle />] : []),
606
- <ResizablePanel key={`panel-${at}`} className="p-2">{child}</ResizablePanel>,
607
- ])}
608
- </ResizablePanelGroup>
609
- ) : (
610
- // `min-w-40` on a field alone so it wraps to its own line on a phone before it is too narrow to
611
- // show its value: `flex-1` alone starts every field at no width, so the row never wraps. A button
612
- // or a short select beside it is not forced to 160 pixels.
613
- <div key={`row-${index}`} data-node="row" className="flex flex-wrap items-start gap-4 [&>[data-control]]:min-w-40 [&>[data-field-value]]:min-w-40 [&>*]:flex-1">
614
- {drawNodes({ nodes: node.row, draw, inRow: true })}
615
- </div>
616
- ),
617
- children: (node) => node.row,
618
- },
619
- separator: { draw: ({ index }) => <Separator key={`separator-${index}`} data-node="separator" />, children: none },
620
- tabs: {
621
- draw: ({ node, draw, index }) => (
622
- <TabGroup
623
- key={`tabs-${index}`}
624
- line={!draw.inCard}
625
- // A tab whose every node its `when` hides is left out, so no actor opens a tab that holds
626
- // nothing it may see.
627
- tabs={node.tabs.filter((tab) => tab.fields.some((one) => shown(one, draw.scope))).map((tab) => {
628
- // Counted over the fields this tab holds at any depth, so a
629
- // refused field inside a row inside a tab is counted on the tab.
630
- const invalid = leavesOf(tab.fields).filter((name) => draw.issues[name] !== undefined).length
631
- return {
632
- key: tab.tab,
633
- ...groupCopy({ kayan: draw.kayan, name: tab.tab, group: tab, copy: draw.copy }),
634
- invalid,
635
- invalidLabel: (
636
- draw.copy.t('admin.invalidFields', 'Fields needing attention: {count}') ?? 'Fields needing attention: {count}'
637
- ).replace('{count}', String(invalid)),
638
- body: <div className="flex flex-col gap-4">{carded(tab.fields, draw)}</div>,
639
- }
640
- })}
641
- />
642
- ),
643
- children: (node) => node.tabs.flatMap((tab) => tab.fields),
644
- },
645
- accordion: {
646
- draw: ({ node, draw, index }) => (
647
- <Accordion data-layout-accordion="true" data-node="accordion" key={`accordion-${index}`} defaultValue={node.accordion[0] ? [node.accordion[0].item] : []} keepMounted className="overflow-hidden rounded-lg border">
648
- {node.accordion.map((item) => {
649
- const { label, description } = groupCopy({ kayan: draw.kayan, name: item.item, group: item, copy: draw.copy })
650
- return <AccordionItem key={item.item} value={item.item} className="border-b last:border-b-0">
651
- <AccordionHeader><AccordionTrigger>{label}<ChevronDown aria-hidden="true" className="size-4" /></AccordionTrigger></AccordionHeader>
652
- <AccordionPanel className="flex flex-col gap-4 p-4">
653
- {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
654
- {drawNodes({ nodes: item.fields, draw: { ...draw, inCard: true } })}
655
- </AccordionPanel>
656
- </AccordionItem>
657
- })}
658
- </Accordion>
659
- ),
660
- children: (node) => node.accordion.flatMap((item) => item.fields),
661
- },
662
- section: {
663
- draw: ({ node, draw, inRow }) => {
664
- const { label, description } = groupCopy({ kayan: draw.kayan, name: node.section, group: node, copy: draw.copy })
665
- const body = <CardContent className="flex flex-col gap-4">{drawNodes({ nodes: node.fields, draw: { ...draw, inCard: true } })}</CardContent>
666
- // A section is a card that opens and closes. `keepMounted` on the panel so a field inside a closed
667
- // section still renders and a refusal naming it is counted.
668
- return (
669
- <Collapsible key={node.section} data-node="section" defaultOpen={node.collapsed !== true} style={inRow ? { flexGrow: node.width ?? 1, flexBasis: 0 } : undefined}>
670
- <Card className="overflow-visible rounded-lg">
671
- <CardHeader>
672
- <CardTitle>
673
- <h2 className="text-base font-medium">
674
- <CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-2 text-start max-sm:min-h-11">
675
- {label}
676
- <ChevronDown
677
- aria-hidden="true"
678
- className="text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180"
679
- />
680
- </CollapsibleTrigger>
681
- </h2>
682
- </CardTitle>
683
- {description ? <CardDescription>{description}</CardDescription> : null}
684
- </CardHeader>
685
- <CollapsibleContent keepMounted>
686
- {node.scroll ? <ScrollArea style={{ height: node.scroll }}>{body}</ScrollArea> : body}
687
- </CollapsibleContent>
688
- </Card>
689
- </Collapsible>
690
- )
691
- },
692
- children: (node) => node.fields,
693
- },
694
- }
695
-
696
- /**
697
- * A form's nodes, with each run of fields that sits in no section drawn on a card, so a dark page
698
- * reads as cards over the inset and not as boxes on black. A section, tabs and a component are drawn
699
- * as they are.
700
- */
701
- const loose = new Set(['field', 'row', 'separator'])
702
- export const carded = (nodes: readonly Node[], draw: Draw): ReactNode[] => {
703
- const drawn: ReactNode[] = []
704
- let run: Node[] = []
705
- const flush = () => {
706
- const held = run.filter((one) => shown(one, draw.scope) && !blank(one, draw.kayan))
707
- // A separator divides what a card holds, so a card holding nothing else is not drawn.
708
- if (held.some((one) => kindOf(one) !== 'separator')) {
709
- drawn.push(
710
- <Card key={`card-${drawn.length}`} className="overflow-visible rounded-lg">
711
- <CardContent className="flex flex-col gap-4">{drawNodes({ nodes: held, draw: { ...draw, inCard: true } })}</CardContent>
712
- </Card>,
713
- )
714
- }
715
- run = []
716
- }
717
- for (const one of nodes) {
718
- if (loose.has(kindOf(one)!)) {
719
- run.push(one)
720
- continue
721
- }
722
- flush()
723
- drawn.push(<Fragment key={`node-${drawn.length}`}>{drawNodes({ nodes: [one], draw })}</Fragment>)
724
- }
725
- flush()
726
- return drawn
727
- }
728
-
729
- /**
730
- * Whether a node would draw nothing but empty frames: a leaf naming a field the projection does not
731
- * hold, which is a field the viewer may not read, or a row, section, tab strip or accordion holding
732
- * only such leaves. Such a group is not drawn, rather than drawn as an empty card.
733
- */
734
- const blank = (node: Node, kayan: ClientKayan): boolean => {
735
- const kind = kindOf(node)
736
- if (kind === 'field') return !kayan.fields.some((field) => field.name === (typeof node === 'string' ? node : (node as LeafClient).field))
737
- return ['row', 'section', 'tabs', 'accordion'].includes(kind!) && inside(node).every((one) => blank(one, kayan))
738
- }
739
-
740
- /** One tree, one node at a time, each through its kind's entry in the one map. */
741
- export const drawNodes = ({ nodes, draw, inRow = false }: { nodes: readonly Node[]; draw: Draw; inRow?: boolean }): ReactNode[] =>
742
- nodes.map((node, index) =>
743
- !shown(node, draw.scope) || blank(node, draw.kayan)
744
- ? null
745
- : (nodeMap[kindOf(node)!].draw as (args: { node: Node; draw: Draw; index: number; inRow: boolean }) => ReactNode)({ node, draw, index, inRow }),
746
- )
747
-
748
- /**
749
- * Whether a node draws anything: shown, not blank, a button its page draws (an operation the report
750
- * allows), and a group holding at least one such node. A danger zone of buttons the viewer may not
751
- * run is no card at all.
752
- */
753
- const draws = (node: Node, draw: Draw): boolean => {
754
- if (!shown(node, draw.scope) || blank(node, draw.kayan)) return false
755
- const kind = kindOf(node)
756
- if (kind === 'button') return (draw.button?.(node as ButtonClient, 0) ?? null) !== null
757
- return !['row', 'section', 'tabs', 'accordion'].includes(kind!) || inside(node).some((one) => draws(one, draw))
758
- }
759
-
760
- /**
761
- * A shell's or a layout's regions, each drawn from its nodes, which is what its
762
- * file is handed. A region none of whose nodes is drawn is handed as nothing,
763
- * so a layout leaves out its frame, a danger zone's heading among them.
764
- */
765
- export const regionsOf = (frame: RegionsClient, draw: Draw): Record<string, ReactNode> =>
766
- Object.fromEntries(regionsIn(frame).map(([region, nodes]) => [region, nodes.some((node) => draws(node, draw)) ? drawNodes({ nodes, draw }) : undefined]))
767
-
768
- /**
769
- * The shell or the layout a page names, handed its regions drawn and the page
770
- * it frames. A name this app has no component for is said where the frame
771
- * would have been rather than drawn as a blank.
772
- */
773
- export const frameOf = ({
774
- screen,
775
- t,
776
- path,
777
- regions,
778
- key,
779
- }: {
780
- screen: ScreenPage
781
- t: Translate
782
- path: string
783
- regions: Record<string, ReactNode>
784
- key?: Key
785
- }): ReactNode => {
786
- const Frame = refOf({ screen, place: 'layout', path })
787
- if (Frame) return <Frame key={key} regions={regions} page={screen.page} />
788
- const missing = t('admin.missingLayout', 'No component is registered for layout "{layout}".') ?? 'No component is registered for layout "{layout}".'
789
- return <p key={key} data-missing-layout={path} className="text-muted-foreground text-sm">{missing.replace('{layout}', path)}</p>
790
- }
791
-
792
- /**
793
- * A page's whole tree: its shell handed each region drawn, or, where the page
794
- * opens over the list and so inside the list's own shell, its regions alone.
795
- */
796
- export const pageOf = (screen: ScreenPage, draw: Draw): ReactNode => {
797
- const drawn = { ...draw, many: listOf(screen) !== undefined }
798
- if (!screen.page.mode) return frameOf({ screen, t: draw.copy.t, path: screen.page.page.shell, regions: regionsOf(screen.page.page, drawn) })
799
- // Over the list the page draws only itself, and a nav node at any depth draws nothing there.
800
- return regionsIn(screen.page.page).map(([region, nodes]) => <Fragment key={region}>{drawNodes({ nodes, draw: { ...drawn, framed: true } })}</Fragment>)
801
- }
802
-
803
- /**
804
- * One field's control in its shell, as the form draws a leaf. The leaf's copy, width and read-only win over the field's;
805
- * a component named by path gets the props its place hands it, any other name is a registry control.
806
- */
807
- export function LeafControl({
808
- field,
809
- kayan,
810
- leaf,
811
- screen,
812
- copy,
813
- error,
814
- invalidLocales,
815
- onBlur,
816
- ...rest
817
- }: Omit<ControlProps, 'id' | 't' | 'locale' | 'placeholder' | 'describedBy' | 'client'> & {
818
- leaf?: LeafClient
819
- screen?: ScreenPage
820
- copy: Copy
821
- /** The locales of a localized field a refusal named by its path, `title.ar`, whose boxes alone are marked. */
822
- invalidLocales?: readonly string[]
823
- onBlur?: () => void
824
- kayans?: readonly ClientKayan[]
825
- api?: FindApi
826
- }): ReactElement {
827
- const { kayans, ...props } = rest
828
- const { locale } = copy
829
- const chosen = leaf?.component ?? field.component
830
- const Ref = refOf({ screen, place: 'control', path: chosen })
831
- const Control = (leaf?.component && controls[leaf.component]) || controlFor(field)
832
- const inputId = `${kayan.name}-${field.name}`
833
- const description = leaf?.description?.[locale] ?? descriptionFor({ kayan, field, copy })
834
- const readOnly = props.readOnly || leaf?.readOnly === true
835
- const placeholder = leaf?.placeholder?.[locale] ?? placeholderFor({ kayan, field, copy })
836
- // A localized field is one box per locale under one label, the viewer's own first and carrying the
837
- // field's id; a change to one box writes the whole object, which is how the store takes it.
838
- if (field.rules.localized === true && !Ref) {
839
- const held = (props.value && typeof props.value === 'object' ? props.value : {}) as Record<string, unknown>
840
- const names = new Intl.DisplayNames([locale], { type: 'language' })
841
- const order = [locale, ...(copy.locales ?? []).filter((one) => one !== locale)]
842
- // Marks the locales a refusal's paths name; before any save, the empty boxes of a required field;
843
- // any other refusal marks every box.
844
- const blank = (one: string) => held[one] === undefined || held[one] === null || held[one] === ''
845
- const marked = (one: string) =>
846
- !error ? false
847
- : invalidLocales?.length ? invalidLocales.includes(one)
848
- : field.rules.required === true && order.some(blank) ? blank(one)
849
- : true
850
- const label = labelFor({ kayan, field, copy })
851
- return (
852
- <FieldShell id={inputId} label={label} description={description} error={error} onBlur={onBlur} control={chosen} width={leaf?.width ?? field.width}>
853
- <div className="grid gap-2">
854
- {order.map((one) => (
855
- <div key={one} className="grid gap-1">
856
- {/* The viewer's own box is named by the field's label, every other box by the label and its locale. */}
857
- {one === locale ? (
858
- <span id={`${inputId}-${one}-locale`} className="text-muted-foreground text-xs">{names.of(one)}</span>
859
- ) : (
860
- <label id={`${inputId}-${one}-locale`} htmlFor={`${inputId}-${one}`} className="text-muted-foreground text-xs">
861
- <span className="sr-only">{label} </span>
862
- {names.of(one)}
863
- </label>
864
- )}
865
- <Control
866
- {...props}
867
- field={field}
868
- kayan={kayan}
869
- client={kayans}
870
- t={copy.t}
871
- locale={locale}
872
- locales={copy.locales}
873
- id={one === locale ? inputId : `${inputId}-${one}`}
874
- value={held[one]}
875
- onChange={(next) => props.onChange({ ...held, [one]: next })}
876
- readOnly={readOnly}
877
- error={marked(one) ? error : undefined}
878
- placeholder={placeholder}
879
- describedBy={[`${inputId}-${one}-locale`, describedBy({ id: inputId, description, error })].filter(Boolean).join(' ')}
880
- />
881
- </div>
882
- ))}
883
- </div>
884
- </FieldShell>
885
- )
886
- }
887
- return (
888
- <FieldShell
889
- id={inputId}
890
- label={labelFor({ kayan, field, copy })}
891
- description={description}
892
- error={error}
893
- onBlur={onBlur}
894
- beside={!Ref && besideLabel(Control)}
895
- control={chosen}
896
- width={leaf?.width ?? field.width}
897
- >
898
- {Ref && screen ? (
899
- <Ref field={field} value={props.value} onChange={props.onChange} readOnly={readOnly} issue={error ?? null} />
900
- ) : (
901
- <Control
902
- {...props}
903
- field={field}
904
- kayan={kayan}
905
- client={kayans}
906
- t={copy.t}
907
- locale={locale}
908
- locales={copy.locales}
909
- id={inputId}
910
- readOnly={readOnly}
911
- error={error}
912
- placeholder={placeholder}
913
- describedBy={describedBy({ id: inputId, description, error })}
914
- />
915
- )}
916
- </FieldShell>
917
- )
918
- }
919
-
920
- /**
921
- * A sheet's or a dialog's close button, an icon named in the viewer's language. The sheet or dialog
922
- * that draws it passes `showCloseButton={false}`, so the primitive's English one is not drawn beside it.
923
- */
924
- export function CloseButton({ Close, label }: { Close: typeof SheetClose | typeof DialogClose; label: string }): ReactElement {
925
- return (
926
- <Close render={<Button variant="ghost" size="icon-sm" className="absolute top-4 end-4 max-sm:top-2 max-sm:end-2 max-sm:size-11" />}>
927
- <XIcon aria-hidden="true" />
928
- <span className="sr-only">{label}</span>
929
- </Close>
930
- )
931
- }
932
-
933
- /**
934
- * The open popups of shadcn's four modals. The sidebar's mobile sheet is a sheet whose popup the
935
- * sidebar renames `data-slot="sidebar"`; the desktop sidebar has the same slot and is never `data-open`.
936
- */
937
- const openModal = '[data-slot=dialog-content][data-open],[data-slot=alert-dialog-content][data-open],[data-slot=sheet-content][data-open],[data-slot=sidebar][data-open],[data-slot=drawer-popup][data-open]'
938
-
939
- /**
940
- * Makes everything outside the open modal `inert`, as `showModal()` does, so Tab never reaches the page behind it:
941
- * Base UI's focus guards act only on focus events, which an unfocused document never receives.
942
- */
943
- export const holdFocus = (body: HTMLElement): (() => void) => {
944
- /** Everything this made inert given back, which is all it ever clears. */
945
- const release = () => {
946
- for (const one of body.querySelectorAll('[data-mithaq-inert]')) {
947
- one.removeAttribute('inert')
948
- one.removeAttribute('data-mithaq-inert')
949
- }
950
- }
951
- // A live region, such as a list's count of ticked rows, keeps speaking while a modal is open: an
952
- // element holding one is walked into rather than made inert whole.
953
- const shut = (node: Element) => {
954
- if (node.matches('[aria-live], script') || node.hasAttribute('inert')) return
955
- if (node.querySelector('[aria-live]')) {
956
- for (const child of node.children) shut(child)
957
- return
958
- }
959
- node.setAttribute('inert', '')
960
- node.setAttribute('data-mithaq-inert', '')
961
- }
962
- const sync = () => {
963
- release()
964
- const open = [...body.querySelectorAll(openModal)].at(-1)
965
- if (!open) return
966
- for (const child of body.children) if (child.hasAttribute('data-base-ui-inert') && !child.contains(open)) shut(child)
967
- }
968
- const observer = new MutationObserver(sync)
969
- observer.observe(body, { childList: true, subtree: true, attributeFilter: ['data-open', 'data-base-ui-inert'] })
970
- sync()
971
- // The cleanup gives the page back whole even when the frame unmounts under an open modal.
972
- return () => {
973
- observer.disconnect()
974
- release()
975
- }
976
- }
977
-
978
- /**
979
- * Focus on open: the first form field, or the popup itself on a touch so the phone's keyboard stays down.
980
- * A route-opened frame gets no interaction type, so on a coarse pointer anything but the keyboard counts as touch.
981
- */
982
- export const initialFocusIn = (popup: RefObject<HTMLElement | null>) => (type: string) =>
983
- type === 'touch' || (type !== 'keyboard' && globalThis.matchMedia?.('(pointer: coarse)').matches)
984
- ? popup.current
985
- : popup.current?.querySelector<HTMLElement>('form :is(input:not([type=hidden]), textarea, select, [contenteditable=true], [role=combobox]):not(:disabled, [aria-hidden=true], [tabindex="-1"])') ?? true
986
-
987
- /**
988
- * Where a create or an edit opens over the list, when its page names a mode:
989
- * shadcn's dialog, sheet or drawer, open while it is drawn, and closing it is
990
- * the host's navigation back to the list.
991
- */
992
- export function RecordFrame({
993
- mode,
994
- title,
995
- closeLabel,
996
- onClose,
997
- children,
998
- }: {
999
- mode: 'dialog' | 'drawer' | 'sheet'
1000
- title: string
1001
- /** What a screen reader names the close button by, in the viewer's language. */
1002
- closeLabel: string
1003
- onClose: () => void
1004
- children?: ReactNode
1005
- }): ReactElement {
1006
- const change = (open: boolean) => {
1007
- if (!open) onClose()
1008
- }
1009
- const popup = useRef<HTMLDivElement>(null)
1010
- const frame =
1011
- // The title is for a screen reader alone, as the page draws its own title node inside. The close
1012
- // button is this file's own, as the primitive's is named in English.
1013
- mode === 'sheet' ? <Sheet open onOpenChange={change}><SheetContent ref={popup} showCloseButton={false} initialFocus={initialFocusIn(popup)} className="gap-4 overflow-y-auto p-4 data-[side=left]:max-sm:w-full data-[side=right]:max-sm:w-full"><SheetTitle className="sr-only">{title}</SheetTitle>{children}<CloseButton Close={SheetClose} label={closeLabel} /></SheetContent></Sheet>
1014
- : mode === 'drawer' ? <Drawer open onOpenChange={change}><DrawerContent><DrawerTitle className="sr-only">{title}</DrawerTitle><div className="flex min-h-0 flex-col gap-4 overflow-y-auto p-4">{children}</div></DrawerContent></Drawer>
1015
- : <Dialog open onOpenChange={change}><DialogContent ref={popup} showCloseButton={false} initialFocus={initialFocusIn(popup)}><DialogTitle className="sr-only">{title}</DialogTitle>{children}<CloseButton Close={DialogClose} label={closeLabel} /></DialogContent></Dialog>
1016
- // A server render draws nothing of the popup, which is a portal, so the mode is said here.
1017
- return (
1018
- <>
1019
- <span hidden data-record-mode={mode} />
1020
- {frame}
1021
- </>
1022
- )
1023
- }
1024
- // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 d6e35be14e1300296cbe3b2d1330322351a5f45e94e6ae3340b8b6f5728cbc0f