create-mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1056 -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,1467 +0,0 @@
1
- import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from 'react'
2
- import { useField, useForm, useStore, type AnyFormApi } from '@tanstack/react-form'
3
- import { Check } from 'lucide-react'
4
- import { compareDecimals, decimalDigits, isDecimal, type ClientField, type ClientKayan, type LeafClient, type Row } from 'mithaq/admin'
5
- import { FormContext, ScreenContext } from 'mithaq/admin/react'
6
- import {
7
- AlertDialog,
8
- AlertDialogAction,
9
- AlertDialogCancel,
10
- AlertDialogContent,
11
- AlertDialogDescription,
12
- AlertDialogFooter,
13
- AlertDialogHeader,
14
- AlertDialogTitle,
15
- AlertDialogTrigger,
16
- } from '@/components/ui/alert-dialog'
17
- import { Badge } from '@/components/ui/badge'
18
- import { Button } from '@/components/ui/button'
19
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
20
- import { Spinner } from '@/components/ui/spinner'
21
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
22
- import { cellFor } from './cells.tsx'
23
- import { RichTextValueView } from './rich-text.tsx'
24
- import { UploadValue } from './upload.tsx'
25
- import { useRelationshipTitles } from './relationship-cell.tsx'
26
- import {
27
- besideLabel,
28
- controlFor,
29
- counted,
30
- descriptionFor,
31
- descriptionOf,
32
- labelFor,
33
- listed,
34
- localesOf,
35
- operationFieldLabelFor,
36
- operationLabelFor,
37
- placeholderFor,
38
- readOnlyField,
39
- refusalKeyOf,
40
- seedOf,
41
- titleOf,
42
- valuesOf,
43
- type Copy,
44
- type FindApi,
45
- type Translate,
46
- } from './controls.tsx'
47
- import { FieldShell, describedBy } from './field-shell.tsx'
48
- import { BulkChoice, LeafControl, NavLinks, NodeButton, OwnButton, initialFocusIn, rowWarning, drawNodes, drawsKind, frameOf, leavesOf, nodesOf, pageButtonOf, pageOf, refOf, scopeOf, type Draw, type Node, type PageButton, type ScreenPage } from './nodes.tsx'
49
-
50
- /**
51
- * The three operations this screen drives, typed structurally so a typed oRPC client, an in-process
52
- * router client or a plain object fits.
53
- */
54
- export type FormClient = {
55
- create: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>
56
- update: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>
57
- delete: (input: Record<string, unknown>) => Promise<unknown>
58
- /** A kayan's own operation, by name, for a page button that asks for one. A host that hands none draws no such button. */
59
- run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>
60
- /** The plain read of one row; on a kayan that keeps drafts, the published copy. A host that hands none draws no draft switch. */
61
- findByID?: (input: Record<string, unknown>) => Promise<unknown>
62
- }
63
-
64
- /** What a write asks beside its input on a kayan that keeps drafts: a draft save, and an autosave beside it. */
65
- export type DraftOptions = { readonly draft?: boolean; readonly autosave?: boolean }
66
-
67
- /** Where a row of a kayan that keeps drafts stands: never published, published, or published with a newer draft pending. */
68
- export type DraftStatus = 'draft' | 'published' | 'changed'
69
-
70
- const afterDraft = (status?: DraftStatus): DraftStatus => (status === 'published' || status === 'changed' ? 'changed' : 'draft')
71
-
72
- const pills: Record<DraftStatus, string> = {
73
- draft: 'bg-muted text-muted-foreground',
74
- published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
75
- changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
76
- }
77
-
78
- const publishColour = 'bg-emerald-700 text-white hover:bg-emerald-700/90 focus-visible:border-emerald-600 focus-visible:ring-emerald-600/40 dark:bg-emerald-500 dark:text-emerald-950 dark:hover:bg-emerald-500/90 dark:focus-visible:ring-emerald-500/40'
79
- const draftColour = 'border-transparent bg-amber-500 text-amber-950 hover:bg-amber-500/90 hover:text-amber-950 focus-visible:border-amber-600 focus-visible:ring-amber-500/50 dark:bg-amber-400 dark:hover:bg-amber-400/90'
80
-
81
- export type FormProps = {
82
- kayan: ClientKayan
83
- client: FormClient
84
- /** The row being edited, or nothing for a row being written. */
85
- value?: Record<string, unknown> | null
86
- t: Translate
87
- locale: string
88
- /** Which locales exist, from the host: a localized field draws one box for each. */
89
- locales?: readonly string[]
90
- /**
91
- * What the host does once a write lands. The second argument is the effect that ran (`create`,
92
- * `update` or `delete`), which decides where the host lands.
93
- */
94
- onDone: (row: unknown, effect?: string) => void
95
- /** Where Cancel goes. A host that hands none gets no Cancel button. */
96
- onCancel?: () => void
97
- /** A refusal the host already caught, for a host that writes on its own path too. */
98
- error?: unknown
99
- /** Every kayan of the build, so a relationship control can find its target. */
100
- kayans?: readonly ClientKayan[]
101
- /** The typed client's reads, for a control that pages a second kayan. */
102
- api?: FindApi
103
- /**
104
- * Whether this actor may write one field on the operation being rendered. Chrome only: the server
105
- * authorizes the write whatever this says. Undeclared is allowed.
106
- */
107
- writes?: (field: string, on: 'create' | 'update') => boolean
108
- /**
109
- * This actor's answer per operation name. A button for a kayan's own operation reads its own name
110
- * here, never its effect's. Chrome only; an absent key is denied.
111
- */
112
- operations?: Readonly<Record<string, boolean>>
113
- /**
114
- * Whether this form holds a change nobody saved, reported each time the answer changes, so the
115
- * host's router can block a navigation away.
116
- */
117
- onDirty?: (dirty: boolean) => void
118
- /**
119
- * A navigation the host's router held back while this form reported a change. The form asks the
120
- * discard question itself so the autosave is held while it asks: `proceed` lets the navigation go,
121
- * `reset` keeps the person here.
122
- */
123
- blocked?: { proceed: () => void; reset: () => void }
124
- /** A screen's page, where this form draws one: its regions go to the page's layout and nothing the page does not name is drawn. */
125
- screen?: ScreenPage
126
- /** What the host draws a list node on this page with. A host that hands none draws no list. */
127
- related?: Draw['list']
128
- /** The nodes this form draws in place of a page, as one form node and no shell. They win over `screen`'s page. */
129
- nodes?: readonly Node[]
130
- /**
131
- * The kayan keeps drafts and this actor holds its `findVersions`: the form offers Save draft and
132
- * Publish changes in place of Save, and autosaves a draft after `autosave` ms of stillness.
133
- * A host that hands none keeps the plain Save.
134
- */
135
- drafts?: { status?: DraftStatus; autosave?: number }
136
- /**
137
- * The kayan's update takes `expectedUpdatedAt`: an update sends the `updatedAt` this form last read
138
- * or wrote, so a save over a newer one from another tab is refused 409. On a kayan that keeps
139
- * drafts it is the latest version's, and an autosave sends it too.
140
- */
141
- expectedUpdatedAt?: boolean
142
- /**
143
- * The kayan's other operations that take `expectedUpdatedAt`, of `delete` and `restoreVersion`, so
144
- * a newer draft another person saved is kept.
145
- */
146
- expectedUpdatedAtOn?: readonly string[]
147
- }
148
-
149
- /**
150
- * Which fields a schema refusal names, and what it says about each, keyed by the first segment of
151
- * the issue's path (the field the form drew a control for).
152
- */
153
- export const issuesOf = (error: unknown): Readonly<Record<string, string>> => {
154
- const issues = (error as { data?: { params?: { issues?: unknown } } } | null | undefined)?.data?.params?.issues
155
- // No prototype, so a field named `toString` has no issue it was never given.
156
- const byField: Record<string, string> = Object.create(null)
157
- if (!Array.isArray(issues)) return byField
158
- for (const issue of issues as readonly { path?: unknown; message?: unknown }[]) {
159
- const path = typeof issue?.path === 'string' ? issue.path.split('.')[0] : ''
160
- if (!path || typeof issue.message !== 'string' || byField[path] !== undefined) continue
161
- byField[path] = issue.message
162
- }
163
- return byField
164
- }
165
-
166
- /** An email address, by the pattern the server's `email` kind runs, copied so the browser loads no zod. */
167
- const emailShape = /^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/
168
-
169
- /** Whether a value is nothing a person wrote. An empty list is a value, so a required list may be saved empty; `minRows` asks for rows. */
170
- const empty = (value: unknown) => value === undefined || value === null || value === ''
171
-
172
- /**
173
- * The first rule of the projection this value breaks, as the last segment of the server's copy key,
174
- * in the server's order, or nothing. An empty box passes every rule but `required`; a value outside
175
- * `options` is `invalid`.
176
- */
177
- const brokenRule = (field: ClientField, held: unknown): string | undefined => {
178
- const { rules } = field
179
- if (rules.localized === true && held && typeof held === 'object') {
180
- return Object.values(held).map((one) => brokenRule({ ...field, rules: { ...rules, localized: false } }, one)).find(Boolean)
181
- }
182
- // The server trims and cases before it judges, so judge the rewritten value.
183
- const text = typeof held === 'string' && rules.trim === true ? held.trim() : held
184
- const value = typeof text !== 'string' ? text : rules.uppercase === true ? text.toUpperCase() : rules.lowercase === true ? text.toLowerCase() : text
185
- if (Array.isArray(value) && (value.length || rules.required === true)) {
186
- if (typeof rules.minRows === 'number' && value.length < rules.minRows) return 'minRows'
187
- if (typeof rules.maxRows === 'number' && value.length > rules.maxRows) return 'maxRows'
188
- }
189
- if (empty(value)) return rules.required === true ? 'required' : undefined
190
- if (field.kind === 'integer' && !(Number.isInteger(value) && (value as number) >= -2147483648 && (value as number) <= 2147483647)) return 'invalid'
191
- if (field.kind === 'decimal') {
192
- if (!isDecimal(value)) return 'invalid'
193
- const { whole, fraction } = decimalDigits(value)
194
- if (typeof rules.precision === 'number' && (whole + fraction > rules.precision || whole > rules.precision - Number(rules.scale))) return 'precision'
195
- if (typeof rules.scale === 'number' && fraction > rules.scale) return 'scale'
196
- if (typeof rules.min === 'string' && compareDecimals(value, rules.min) < 0) return 'min'
197
- if (typeof rules.max === 'string' && compareDecimals(value, rules.max) > 0) return 'max'
198
- return undefined
199
- }
200
- if (field.kind === 'email' && typeof value === 'string' && !emailShape.test(value)) return 'invalid'
201
- const size = typeof value === 'string' || Array.isArray(value) ? value.length : undefined
202
- if (size !== undefined && typeof rules.minLength === 'number' && size < rules.minLength) return 'minLength'
203
- if (size !== undefined && typeof rules.maxLength === 'number' && size > rules.maxLength) return 'maxLength'
204
- if (typeof value === 'string' && rules.pattern instanceof RegExp && !new RegExp(rules.pattern).test(value)) return 'pattern'
205
- const { min, max } = rules
206
- if (typeof min === 'number' ? typeof value === 'number' && value < min : typeof min === 'string' && String(value) < min) return 'min'
207
- if (typeof max === 'number' ? typeof value === 'number' && value > max : typeof max === 'string' && String(value) > max) return 'max'
208
- if (field.options && [value].flat().some((one) => !field.options!.some((option) => option.value === one))) return 'invalid'
209
- return undefined
210
- }
211
-
212
- /**
213
- * The refusal the server would send for these values, in its wire shape, or nothing when every rule
214
- * holds. The rules are checked here because the derived zod shape does not cross `structuredClone`
215
- * into the browser; the server stays the authority.
216
- */
217
- export const refusalOf = ({
218
- kayan,
219
- fields,
220
- values,
221
- }: {
222
- kayan: string
223
- fields: readonly ClientField[]
224
- values: Readonly<Record<string, unknown>>
225
- }) => {
226
- const issues = fields.flatMap((field) => {
227
- const rule = brokenRule(field, values[field.name])
228
- return rule ? [{ path: field.name, message: `${kayan}.${field.name}.${rule}` }] : []
229
- })
230
- return issues.length ? { data: { key: 'error.invalid.value', params: { issues } } } : undefined
231
- }
232
-
233
- /** The sentence over a refused save: a schema refusal that names fields lists them by label; any other is its own key's sentence, or `fallback`. */
234
- export const refusalWords = ({ shown, labels, fallback, copy: { t, locale } }: { shown: string; labels: readonly string[]; fallback?: string; copy: Copy }): string =>
235
- shown === 'error.invalid.value' && labels.length
236
- ? counted({ t, locale, key: 'error.invalid.fields', count: labels.length, fallback: 'The following fields are invalid: {fields}' }).replace('{fields}', () => listed(labels, locale))
237
- : (t(shown, fallback ?? shown) ?? shown)
238
-
239
- const KayanForm = createContext<
240
- | {
241
- form: AnyFormApi
242
- issues: Readonly<Record<string, string>>
243
- locales: Readonly<Record<string, readonly string[]>>
244
- writes?: (field: string, on: 'create' | 'update') => boolean
245
- on: 'create' | 'update'
246
- check: (name: string, value: unknown) => string | undefined
247
- forget: (name: string) => void
248
- }
249
- | undefined
250
- >(undefined)
251
-
252
- export const useValues = <Values extends object = Record<string, unknown>>(): Values => {
253
- const held = useContext(KayanForm)
254
- if (!held) throw new Error('useValues is called inside the form this registry draws, and nowhere else')
255
- return useStore(held.form.store, (state) => state.values as Values)
256
- }
257
-
258
- type KayanField = {
259
- value: unknown
260
- onChange: (value: unknown) => void
261
- onBlur: () => void
262
- /** Whether this field is the client's to write on this operation, off the access report. */
263
- writable: boolean
264
- /** Whether a person has left this box, or the server has refused it. */
265
- touched: boolean
266
- /** This field's refusal, in the words a person reads, or nothing. */
267
- error?: string
268
- /** The locales of a localized field the server's refusal named, whose boxes alone are marked. */
269
- locales?: readonly string[]
270
- }
271
- /**
272
- * One field's value, change, blur, write access and error, read from the form this field is inside.
273
- * The error shows once the box is left or the server has refused it, and falls back to the form's
274
- * refusal record because a remount or a server render has no error map.
275
- */
276
- export function useKayanField<Values extends object, Name extends keyof Values & string>(name: Name): Omit<KayanField, 'value'> & { value: Values[Name] }
277
- export function useKayanField(name: string): KayanField
278
- export function useKayanField(name: string): KayanField {
279
- const held = useContext(KayanForm)
280
- if (!held) throw new Error('useKayanField is called inside the form this registry draws, and nowhere else')
281
- const { check, forget, form, issues, locales, on, writes } = held
282
- const field = useField({
283
- form,
284
- name,
285
- // Validated on blur, then on every change; a change also drops the refusal a save left on this box.
286
- validators: { onBlur: ({ value }) => check(name, value) },
287
- listeners: {
288
- onChange: ({ fieldApi }) => {
289
- forget(name)
290
- if (fieldApi.state.meta.isBlurred) fieldApi.validate('blur')
291
- },
292
- },
293
- })
294
- const meta = field.state.meta
295
- const refused = (meta.errorMap.onServer as string | undefined) ?? issues[name]
296
- const touched = meta.isTouched || refused !== undefined
297
- const invalid = !meta.isValid || refused !== undefined
298
- const first = meta.errors.find((one) => one !== undefined && one !== null)
299
- return {
300
- value: field.state.value,
301
- onChange: (next: unknown) => field.handleChange(next),
302
- onBlur: field.handleBlur,
303
- writable: writes ? writes(name, on) : true,
304
- touched,
305
- error: touched && invalid ? (refused ?? (typeof first === 'string' ? first : undefined)) : undefined,
306
- locales: locales[name],
307
- }
308
- }
309
-
310
- /**
311
- * The delete, in two clicks. The dialog stays open until the delete lands, so a refusal is said inside it.
312
- * With `open` handed in, the dialog is controlled and draws no trigger, so a menu can open it.
313
- */
314
- export function DeleteButton({
315
- busy,
316
- copy,
317
- kayan,
318
- label,
319
- onDelete,
320
- onOpenChange,
321
- open: given,
322
- row,
323
- }: {
324
- busy: boolean
325
- copy: Copy
326
- kayan: ClientKayan
327
- label: string
328
- /** Runs the delete, handing a refusal to `refuse`, and answers whether it landed, which is what the dialog closes on. */
329
- onDelete: (refuse: (thrown: unknown) => void) => Promise<boolean>
330
- open?: boolean
331
- onOpenChange?: (open: boolean) => void
332
- /** The row it deletes, which the dialog names. */
333
- row?: Row
334
- }): ReactElement {
335
- const { t } = copy
336
- const [held, setHeld] = useState(false)
337
- const [refused, setRefused] = useState<unknown>(undefined)
338
- const setOpen = (next: boolean) => {
339
- if (!next) setRefused(undefined)
340
- ;(onOpenChange ?? setHeld)(next)
341
- }
342
- return (
343
- <AlertDialog open={given ?? held} onOpenChange={setOpen}>
344
- {given === undefined ? (
345
- <AlertDialogTrigger
346
- render={
347
- <Button type="button" variant="destructive" className="max-sm:h-11" data-button="delete" disabled={busy}>
348
- {label}
349
- </Button>
350
- }
351
- />
352
- ) : null}
353
- <AlertDialogContent>
354
- <AlertDialogHeader>
355
- <AlertDialogTitle>{label}</AlertDialogTitle>
356
- <AlertDialogDescription>
357
- {[row ? rowWarning({ kayan, row, operation: 'delete', copy }) : undefined, t('admin.deleteWarning', 'This cannot be undone.') ?? 'This cannot be undone.'].filter(Boolean).join(' ')}
358
- </AlertDialogDescription>
359
- </AlertDialogHeader>
360
- {refused === undefined ? null : (
361
- <p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
362
- {deleteRefusal({ refused, t })}
363
- </p>
364
- )}
365
- <AlertDialogFooter className="max-sm:[&_button]:h-11">
366
- <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
367
- <AlertDialogAction
368
- variant="destructive"
369
- disabled={busy}
370
- onClick={async () => {
371
- setRefused(undefined)
372
- if (await onDelete(setRefused)) setOpen(false)
373
- }}
374
- >
375
- {t(`${kayan.name}.delete.confirm`) ?? t('admin.deleteConfirm', 'Delete for good') ?? 'Delete for good'}
376
- </AlertDialogAction>
377
- </AlertDialogFooter>
378
- </AlertDialogContent>
379
- </AlertDialog>
380
- )
381
- }
382
-
383
- const deleteRefusal = ({ refused, t }: { refused: unknown; t: Translate }): string => {
384
- const key = refusalKeyOf(refused)
385
- if (!key) return t('admin.deleteFailed', 'Something went wrong, so nothing was deleted. Try again.') ?? 'Something went wrong, so nothing was deleted. Try again.'
386
- const by = (refused as { data?: { params?: { by?: unknown } } }).data?.params?.by
387
- if (key === 'error.conflict.referenced' && typeof by === 'string') {
388
- const kind = t(`${by}.plural`, by) ?? by
389
- return (t('error.conflict.referencedBy', '{kind} still use this, so it is not deleted.') ?? '{kind} still use this, so it is not deleted.').replace('{kind}', () => kind)
390
- }
391
- return t(key, key) ?? key
392
- }
393
-
394
- /**
395
- * The question asked before an unsaved edit is thrown away, used by the form's Cancel and by a
396
- * navigation the host's router blocked. It stays mounted with the form, so an autosave landing while
397
- * it asks cannot unmount it.
398
- */
399
- export function DiscardDialog({
400
- busy,
401
- note,
402
- onDiscard,
403
- onOpenChange,
404
- open,
405
- t,
406
- }: {
407
- open: boolean
408
- onOpenChange?: (open: boolean) => void
409
- /** What discarding means, which is leaving: the Cancel's navigation, or the one the router blocked. */
410
- onDiscard: () => void
411
- /** A sentence after the question, such as that a draft already saved is kept. */
412
- note?: string
413
- busy?: boolean
414
- t: Translate
415
- }): ReactElement {
416
- return (
417
- <AlertDialog open={open} onOpenChange={onOpenChange}>
418
- <AlertDialogContent>
419
- <AlertDialogHeader>
420
- <AlertDialogTitle>{t('admin.discard', 'Discard changes') ?? 'Discard changes'}</AlertDialogTitle>
421
- <AlertDialogDescription>
422
- {[t('admin.discardWarning', 'You have unsaved changes. Leave without saving?') ?? 'You have unsaved changes. Leave without saving?', note].filter(Boolean).join(' ')}
423
- </AlertDialogDescription>
424
- </AlertDialogHeader>
425
- <AlertDialogFooter className="max-sm:[&_button]:h-11">
426
- <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
427
- <AlertDialogAction variant="destructive" disabled={busy} onClick={onDiscard}>
428
- {t('admin.discard', 'Discard changes') ?? 'Discard changes'}
429
- </AlertDialogAction>
430
- </AlertDialogFooter>
431
- </AlertDialogContent>
432
- </AlertDialog>
433
- )
434
- }
435
-
436
- /**
437
- * One of a kayan's own operations, as a button and the dialog behind it. The dialog is controlled so a
438
- * refusal keeps it open over the boxes it names; a list's own buttons open this same dialog.
439
- */
440
- export function OperationButton({
441
- api,
442
- busy,
443
- copy,
444
- description,
445
- inputOf,
446
- kayan,
447
- kayans,
448
- operation,
449
- run,
450
- ...controlled
451
- }: {
452
- api?: FindApi
453
- busy: boolean
454
- copy: Copy
455
- /** Handed in by a caller that opens the dialog from elsewhere, such as a list row's menu, in which case no trigger is drawn. */
456
- open?: boolean
457
- onOpenChange?: (open: boolean) => void
458
- /** What the dialog says it runs on, the row being edited unless the caller says otherwise. */
459
- description?: string
460
- /** The call's input from what the boxes hold: the row's id beside them on a form or list row, the ticked rows' `where` around them on a bulk. */
461
- inputOf: (values: Record<string, unknown>) => Record<string, unknown>
462
- kayan: ClientKayan
463
- kayans?: readonly ClientKayan[]
464
- operation: PageButton
465
- /** The form's own writer, which lands the call and hands a refusal back here. */
466
- run: (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => Promise<boolean>
467
- }): ReactElement {
468
- const { t } = copy
469
- const [held, setHeld] = useState(false)
470
- const open = controlled.open ?? held
471
- const setOpen = (next: boolean) => (controlled.onOpenChange ?? setHeld)(next)
472
- const [values, setValues] = useState<Record<string, unknown>>({})
473
- const [refused, setRefused] = useState<unknown>(undefined)
474
- const fields = operation.fields ?? []
475
- const setValue = (name: string, next: unknown) => {
476
- if (!busy && fields.some((field) => field.name === name)) setValues((already) => ({ ...already, [name]: next }))
477
- }
478
- // Closing drops the refusal and the typed values, so the next opening starts empty.
479
- const close = () => {
480
- setOpen(false)
481
- setRefused(undefined)
482
- setValues({})
483
- }
484
- const label = operationLabelFor({ kayan, operation, copy })
485
- const shown = refusalKeyOf(refused)
486
- const issues = issuesOf(refused)
487
- // A bulk update whose boxes are row data: a box left empty leaves each row's value as it is, and
488
- // with every box empty there is nothing to write, which the server refuses too.
489
- const writesData = operation.updatesMany === true && fields.length > 0
490
- const popup = useRef<HTMLDivElement>(null)
491
-
492
- const submit = async (event: FormEvent<HTMLFormElement>) => {
493
- event.preventDefault()
494
- // React events from a portaled dialog still reach the screen's form through the React tree;
495
- // without this one click would run the operation and save the row.
496
- event.stopPropagation()
497
- if (busy) return
498
- // An untouched box is left out rather than sent as `undefined`, so a schema default for it still applies.
499
- const typed: Record<string, unknown> = {}
500
- for (const field of fields) {
501
- if (values[field.name] !== undefined) typed[field.name] = values[field.name]
502
- }
503
- if (writesData && !Object.keys(typed).length) return setRefused({ data: { key: 'admin.bulkNothingFilled' }, fallback: 'Fill in at least one field.' })
504
- if (await run(inputOf(typed), setRefused)) close()
505
- }
506
-
507
- return (
508
- <AlertDialog
509
- open={open}
510
- onOpenChange={(next: boolean) => (next ? setOpen(true) : close())}
511
- >
512
- {controlled.open === undefined ? (
513
- <AlertDialogTrigger
514
- render={
515
- <Button type="button" variant="outline" className="max-sm:h-11" disabled={busy}>
516
- {label}
517
- </Button>
518
- }
519
- />
520
- ) : null}
521
- {/* On a phone the dialog takes focus, not its first box, so the keyboard stays down. */}
522
- <AlertDialogContent ref={popup} initialFocus={initialFocusIn(popup)}>
523
- <AlertDialogHeader>
524
- <AlertDialogTitle>{operation.confirm?.title[copy.locale] ?? label}</AlertDialogTitle>
525
- <AlertDialogDescription>
526
- {[description, operation.confirm?.body[copy.locale], writesData && t('admin.bulkFilledOnly', 'Only the fields you fill in change.')].filter(Boolean).join(' ')
527
- || (t('admin.operationWarning', 'This runs on the row you are editing.') ?? 'This runs on the row you are editing.')}
528
- </AlertDialogDescription>
529
- </AlertDialogHeader>
530
- {shown ? (
531
- <p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
532
- {refusalWords({ shown, fallback: (refused as { fallback?: string }).fallback, labels: fields.filter((field) => issues[field.name] !== undefined).map((field) => operationFieldLabelFor({ kayan, operation, field, copy })), copy })}
533
- </p>
534
- ) : null}
535
- {/* A form and not a div, so Enter in a one-box dialog submits. */}
536
- <form className="grid gap-4" noValidate onSubmit={(event) => void submit(event)}>
537
- {fields.map((field) => {
538
- const Control = controlFor(field)
539
- const inputId = `${kayan.name}-${operation.name}-${field.name}`
540
- const issue = issues[field.name]
541
- const failed = issue === undefined ? undefined : (t(issue, issue) ?? issue)
542
- const choice = writesData && besideLabel(Control)
543
- return (
544
- <FieldShell
545
- key={field.name}
546
- id={inputId}
547
- label={operationFieldLabelFor({ kayan, operation, field, copy })}
548
- error={failed}
549
- beside={!choice && besideLabel(Control)}
550
- width={field.width}
551
- >
552
- {choice ? (
553
- <BulkChoice
554
- copy={copy}
555
- describedBy={describedBy({ id: inputId, description: undefined, error: failed })}
556
- error={failed}
557
- field={field}
558
- id={inputId}
559
- kayan={kayan}
560
- onChange={(next) => setValue(field.name, next)}
561
- readOnly={busy}
562
- value={values[field.name]}
563
- />
564
- ) : <Control
565
- field={field}
566
- kayan={kayan}
567
- client={kayans}
568
- api={api}
569
- t={t}
570
- id={inputId}
571
- value={values[field.name]}
572
- values={values}
573
- setValue={setValue}
574
- onChange={(next) => setValue(field.name, next)}
575
- readOnly={busy}
576
- error={failed}
577
- describedBy={describedBy({ id: inputId, description: undefined, error: failed })}
578
- />}
579
- </FieldShell>
580
- )
581
- })}
582
- <AlertDialogFooter className="max-sm:[&_button]:h-11">
583
- <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
584
- <AlertDialogAction type="submit" disabled={busy}>
585
- {label}
586
- </AlertDialogAction>
587
- </AlertDialogFooter>
588
- </form>
589
- </AlertDialogContent>
590
- </AlertDialog>
591
- )
592
- }
593
-
594
-
595
- function BoundField({
596
- api,
597
- copy,
598
- field,
599
- form,
600
- kayan,
601
- kayans,
602
- leaf,
603
- readOnly,
604
- screen,
605
- values,
606
- setValue,
607
- }: {
608
- api?: FindApi
609
- copy: Copy
610
- field: ClientField
611
- /** The id of the form this field is saved by, wherever the page drew it. */
612
- form: string
613
- kayan: ClientKayan
614
- kayans?: readonly ClientKayan[]
615
- /** What this page alone draws the field with, on a screen's page. */
616
- leaf?: LeafClient
617
- /** Whether the control takes no edit, which is the form's own answer and never this field's to decide. */
618
- readOnly: boolean
619
- screen?: ScreenPage
620
- values: Readonly<Record<string, unknown>>
621
- setValue: (name: string, value: unknown) => void
622
- }): ReactElement {
623
- const bound = useKayanField(field.name)
624
- return (
625
- <LeafControl
626
- field={field}
627
- form={form}
628
- kayan={kayan}
629
- kayans={kayans}
630
- leaf={leaf}
631
- screen={screen}
632
- copy={copy}
633
- api={api}
634
- value={bound.value}
635
- values={values}
636
- setValue={setValue}
637
- onChange={bound.onChange}
638
- onBlur={bound.onBlur}
639
- readOnly={readOnly}
640
- error={bound.error}
641
- invalidLocales={bound.locales}
642
- />
643
- )
644
- }
645
-
646
- /**
647
- * One field of a row that is read and never edited: its label over the cell a list column draws it
648
- * with. A field the report hides is not in the projection, so nothing is drawn for it.
649
- */
650
- export function FieldValue({
651
- api,
652
- copy,
653
- field,
654
- kayan,
655
- kayans,
656
- leaf,
657
- row,
658
- screen,
659
- }: {
660
- api?: FindApi
661
- copy: Copy
662
- field: ClientField
663
- kayan: ClientKayan
664
- kayans?: readonly ClientKayan[]
665
- leaf?: LeafClient
666
- row: Readonly<Record<string, unknown>> | null
667
- screen?: ScreenPage
668
- }): ReactElement {
669
- const titles = useRelationshipTitles({ fields: [field], docs: row ? [row] : [], context: { api, client: kayans, locale: copy.locale } })
670
- const label = useId()
671
- const value = row?.[field.name]
672
- const Ref = refOf({ screen, place: 'cell', path: leaf?.cell ?? field.cell })
673
- const Cell = cellFor(field, leaf?.cell)
674
- const description = leaf?.description?.[copy.locale] ?? descriptionFor({ kayan, field, copy })
675
- const width = leaf?.width ?? field.width
676
- return (
677
- <div role="group" aria-labelledby={label} data-field-value={field.name} className="grid gap-1.5" style={width ? { width } : undefined}>
678
- <span id={label} className="text-sm font-medium">{labelFor({ kayan, field, copy })}</span>
679
- <span className="text-sm">
680
- {Ref && screen
681
- ? <Ref field={field} value={value} row={row ?? {}} />
682
- : (leaf?.cell ?? field.cell) === 'richText'
683
- ? <RichTextValueView value={value} t={copy.t} />
684
- : (leaf?.cell ?? field.cell) === 'upload'
685
- ? <UploadValue kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />
686
- : <Cell kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />}
687
- </span>
688
- {description ? <span className="text-muted-foreground text-sm">{description}</span> : null}
689
- </div>
690
- )
691
- }
692
-
693
- const units = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1]] as const
694
-
695
- /** How long ago a moment was, in the viewer's language via `Intl.RelativeTimeFormat`: "2 hours ago", and "now" for the moment just passed or a moment ahead of this clock. */
696
- export const timeAgo = ({ at, now, locale }: { at: Date; now: number; locale: string }) => {
697
- const seconds = Math.min(0, (at.getTime() - now) / 1000)
698
- const [unit, size] = units.find(([, size]) => -seconds >= size) ?? units.at(-1)!
699
- return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)
700
- }
701
-
702
- function UpdatedAgo({ at, t, locale }: { at: Date; t: Translate; locale: string }): ReactElement {
703
- const [now, setNow] = useState(Date.now)
704
- useEffect(() => {
705
- const interval = setInterval(() => setNow(Date.now()), 60_000)
706
- return () => clearInterval(interval)
707
- }, [])
708
- return <>{(t('version.updatedAgo', 'Updated {time}') ?? 'Updated {time}').replace('{time}', timeAgo({ at, now, locale }))}</>
709
- }
710
-
711
- /**
712
- * The form screen over one kayan's `create`, `update` and `delete`, holding its values in TanStack Form.
713
- * It is mounted under a key of the kayan and the row's id, so a host that hands another row gets a form
714
- * seeded from that row, and a refusal in hand goes with it.
715
- */
716
- export function Form(props: FormProps): ReactElement {
717
- const id = String(props.value?.id ?? '')
718
- // Exception: the row this form's own autosave made on a create. The host's move to its edit page
719
- // hands that row and the form stays mounted, so nothing typed since is lost.
720
- const [held, setHeld] = useState<{ id: string; mount: number; made?: string }>({ id, mount: 0 })
721
- // The made row's id is kept across that move alone, which is how the page knows the row is its own autosave's.
722
- if (held.id !== id) setHeld(held.id === '' && held.made === id ? { ...held, id } : { id, mount: held.mount + 1 })
723
- return <FormScreen key={`${props.kayan.name}:${held.mount}`} {...props} made={held.made} onMade={(made) => setHeld((before) => ({ ...before, made }))} />
724
- }
725
-
726
- function FormScreen({
727
- api,
728
- blocked,
729
- client,
730
- drafts,
731
- error,
732
- expectedUpdatedAt,
733
- expectedUpdatedAtOn,
734
- kayan,
735
- kayans,
736
- locale,
737
- locales,
738
- made,
739
- nodes,
740
- onCancel,
741
- onDirty,
742
- onDone,
743
- onMade,
744
- operations,
745
- related,
746
- t,
747
- screen,
748
- value,
749
- writes,
750
- }: FormProps & { made?: string; onMade: (id: string) => void }): ReactElement {
751
- const copy = { t, locale, locales }
752
- const [refused, setRefused] = useState<unknown>(undefined)
753
- const [busy, setBusy] = useState(false)
754
- // Bumped to remount every control and panel, so a panel reads what it shows afresh.
755
- const [revision, setRevision] = useState(0)
756
- const reload = () => setRevision((already) => already + 1)
757
- // Saves landed since mount (plain, draft, publish, autosave), for panels that keep their own history
758
- // of the row; a draft save never moves `record.updatedAt`.
759
- const [saves, setSaves] = useState(0)
760
- // When the last save landed, shown as the row's last update ahead of its own `updatedAt`.
761
- const [savedAt, setSavedAt] = useState<Date>()
762
- const noteSave = () => {
763
- setSaves((already) => already + 1)
764
- setSavedAt(new Date())
765
- }
766
- // The seed is held in state, not read off `value`: a save makes what it wrote the seed before the host
767
- // reads the row again. `saved` is what the server holds, kept apart because an autosave leaves the seed alone.
768
- const seeded = (values: Record<string, unknown>, status = drafts?.status, updatedAt = value?.updatedAt) => ({ row: value, values, saved: values, status, updatedAt })
769
- const [seed, setSeed] = useState(() => seeded(seedOf(kayan.fields, value)))
770
- // The focused box and its caret when the host handed another row, put back once the new page has
771
- // drawn it: an autosaved create moves to an edit page whose tree is new.
772
- const focused = useRef<{ id: string; start: number | null; end: number | null }>(undefined)
773
- if (seed.row !== value) {
774
- const box = typeof document === 'undefined' ? null : (document.activeElement as HTMLInputElement | null)
775
- focused.current = box?.id ? { id: box.id, start: box.selectionStart ?? null, end: box.selectionEnd ?? null } : undefined
776
- setSeed(seeded(seedOf(kayan.fields, value)))
777
- }
778
- useLayoutEffect(() => {
779
- const was = focused.current
780
- focused.current = undefined
781
- if (!was || document.activeElement?.id === was.id) return
782
- const box = document.getElementById(was.id) as HTMLInputElement | null
783
- box?.focus()
784
- if (was.start !== null) box?.setSelectionRange(was.start, was.end)
785
- }, [seed])
786
- const form = useForm({ defaultValues: seed.values })
787
- const values = useStore(form.store, (state) => state.values as Record<string, unknown>)
788
- // The access report a node's `when` reads as `$user`; none outside a screen.
789
- const access = useContext(ScreenContext)?.access
790
- const held = refused === undefined ? error : refused
791
- const shown = refusalKeyOf(held)
792
- const issues = issuesOf(held)
793
- // The wire message is itself a copy key; a key nothing answers is drawn as it arrived, which is what
794
- // a custom shape that writes none of our keys sends.
795
- const sentences: Record<string, string> = Object.create(null)
796
- for (const [name, issue] of Object.entries(issues)) sentences[name] = t(issue, issue) ?? issue
797
- const byName = new Map(kayan.fields.map((field) => [field.name, field]))
798
- const banner = shown ? refusalWords({ shown, labels: Object.keys(issues).flatMap((name) => (byName.has(name) ? [labelFor({ kayan, field: byName.get(name)!, copy })] : [])), copy }) : undefined
799
- // A create that has autosaved its row edits that row from then on, before
800
- // the host has handed it.
801
- const id = String(value?.id ?? made ?? '')
802
- // A row with an id is being changed, one without is being made; a field may be the client's on one operation and the server's on the other.
803
- const on = id ? 'update' : 'create'
804
-
805
- // Put the refusal where TanStack keeps a server's answer (`onServer`), so a project's own control reading
806
- // `field.state.meta.errors` sees the same sentence the shell draws.
807
- useEffect(() => {
808
- // The slot is typed off an `onServer` validator, which this form declares none of; TanStack
809
- // writes the key anyway, so the cast only bridges the types.
810
- form.setErrorMap({ onServer: { form: undefined, fields: sentences } } as Parameters<AnyFormApi['setErrorMap']>[0])
811
- }, [form, JSON.stringify(sentences)])
812
-
813
- // TanStack's per-field `isDefaultValue` over the whole form, so a value typed back to its seed is no change.
814
- const dirty = useStore(form.store, (state) => !state.isDefaultValue)
815
-
816
- // Guards a half-written row against a closed tab or a typed URL. The browser shows its own sentence;
817
- // cleared on unmount so a form left behind does not hold the guard for the next screen.
818
- useEffect(() => {
819
- window.onbeforeunload = dirty ? () => '' : null
820
- return () => {
821
- window.onbeforeunload = null
822
- }
823
- }, [dirty])
824
-
825
- // The same fact to the host, for navigations the browser's guard cannot see; cleared on unmount so a
826
- // form left behind does not hold the host's blocker.
827
- useEffect(() => {
828
- onDirty?.(dirty)
829
- return () => onDirty?.(false)
830
- }, [dirty, onDirty])
831
-
832
- // Whether the server owns this field on this operation (read-only, `uuid`, a non-client provider, or
833
- // access denied); it decides both the disabled control and whether the field is in the write.
834
- const owned = (field: ClientField) => readOnlyField(field, on) || (writes ? !writes(field.name, on) : false)
835
- // Every field `setValue` changed, which a save counts as named.
836
- const set = useRef(new Set<string>())
837
- const setValue = (name: string, next: unknown) => {
838
- const field = byName.get(name)
839
- if (!busy && !viewing && field && !owned(field)) {
840
- set.current.add(name)
841
- form.setFieldValue(name, next)
842
- }
843
- }
844
- // A field the server provides is not the person's to fix, so it is not checked, as the save does not check it.
845
- const check = (name: string, next: unknown) => {
846
- const field = byName.get(name)
847
- const rule = field && !owned(field) ? brokenRule(field, next) : undefined
848
- if (!rule) return undefined
849
- const key = `${kayan.name}.${name}.${rule}`
850
- return t(key, key) ?? key
851
- }
852
- // Once the refusal names no field it is `null`, not `undefined`, so the host's own refusal does not come back.
853
- const forget = (name: string) => {
854
- if (issues[name] === undefined) return
855
- const { data } = held as { data: { params: { issues: { path?: unknown }[] } } }
856
- const rest = data.params.issues.filter((issue) => String(issue.path).split('.')[0] !== name)
857
- setRefused(rest.length ? { ...(held as object), data: { ...data, params: { ...data.params, issues: rest } } } : null)
858
- }
859
- const readOnly = (field: ClientField) => busy || owned(field)
860
-
861
- // A leaf in another region is outside the page's one <form>, so its box names the form by this id
862
- // (the platform's `form` attribute) and Enter in it still saves.
863
- const formId = useId()
864
- const control = (leaf: string | LeafClient) => {
865
- const field = byName.get(typeof leaf === 'string' ? leaf : leaf.field)
866
- // A leaf naming a field the kayan lacks is refused at `buildAsas`, so this fires only for a hand-built
867
- // layout. A field the page names is drawn whoever provides it.
868
- if (!field) return null
869
- // The published copy is drawn read-only; the draft's boxes are not drawn, but what was typed stays in the form's values.
870
- if (viewing) {
871
- return (
872
- <LeafControl
873
- key={`${field.name}-published`}
874
- field={field}
875
- form={formId}
876
- kayan={kayan}
877
- kayans={kayans}
878
- leaf={typeof leaf === 'string' ? undefined : leaf}
879
- screen={screen}
880
- copy={copy}
881
- api={api}
882
- value={showing[field.name]}
883
- values={showing}
884
- setValue={() => {}}
885
- onChange={() => {}}
886
- readOnly
887
- />
888
- )
889
- }
890
- // A leaf the page marks read-only is read as text with its cell's formatter. A field the server
891
- // provides keeps its control, which the side card draws.
892
- if (typeof leaf !== 'string' && leaf.readOnly === true) {
893
- return <FieldValue key={`${field.name}-${revision}`} api={api} copy={copy} field={field} kayan={kayan} kayans={kayans} leaf={leaf} row={values} screen={screen} />
894
- }
895
- return (
896
- <BoundField
897
- key={`${field.name}-${revision}`}
898
- api={api}
899
- copy={copy}
900
- field={field}
901
- form={formId}
902
- kayan={kayan}
903
- kayans={kayans}
904
- leaf={typeof leaf === 'string' ? undefined : leaf}
905
- readOnly={readOnly(field)}
906
- screen={screen}
907
- values={values}
908
- setValue={setValue}
909
- />
910
- )
911
- }
912
-
913
- const draw: Draw = {
914
- kayan,
915
- copy,
916
- issues,
917
- scope: scopeOf({ values, record: value, access, kayan }),
918
- control,
919
- // A component has no field behind it; inside a form it reads and writes the form's values through
920
- // `useForm()`. A reference with no registered component is said, not drawn blank.
921
- component: (path, index) => {
922
- const Ref = refOf({ screen, place: 'component', path })
923
- if (Ref && screen) return <Ref key={`component-${index}-${revision}`} page={screen.page} />
924
- return (
925
- <p key={`component-${index}`} data-missing-component={path} className="text-muted-foreground text-sm">
926
- {(t('admin.missingComponent', 'No component is registered for "{component}".')
927
- ?? 'No component is registered for "{component}".'
928
- ).replace('{component}', path)}
929
- </p>
930
- )
931
- },
932
- layout: ({ path, regions, index }) => screen && frameOf({ screen, t, path, regions, key: index }),
933
- list: related,
934
- nav: () => screen && <NavLinks />,
935
- button: (node) => {
936
- const button = id && client.run && !viewing ? pageButtonOf({ kayan, node, allowed: operations }) : undefined
937
- return button ? buttonOf(button) : null
938
- },
939
- // The page's one <form>, so a list of another kayan beside it, with forms of its own, is never inside it;
940
- // a leaf elsewhere on the page is still this form's value. A page with a controls node draws the actions there.
941
- // `noValidate` so the browser's own bubble never stands in for the field's refusal in the page's language.
942
- form: (children) => (
943
- <form id={formId} noValidate onSubmit={submit} className="flex flex-col gap-6">
944
- {chrome}
945
- <div className={headed ? 'flex flex-col gap-4 border-t pt-6' : 'flex flex-col gap-4'}>
946
- {children}
947
- {controlled ? null : (
948
- <Card>
949
- <CardContent>{actionsOf(false)}</CardContent>
950
- </Card>
951
- )}
952
- </div>
953
- </form>
954
- ),
955
- // Only while the page draws the form: a role page that draws a fixed role in place of its form offers no save.
956
- controls: () => (tree && drawsKind({ nodes: tree, kind: 'form', scope: draw.scope }) ? actionsOf(true) : null),
957
- }
958
- const walk = (nodes: readonly Node[]) => drawNodes({ nodes, draw })
959
- // A screen's page names every field it draws; a form handed no page draws every field in declared
960
- // order, and no join, whose rows are another kayan's.
961
- const tree = nodes ?? (screen && nodesOf(screen.page))
962
- const controlled = !!tree && drawsKind({ nodes: tree, kind: 'controls', scope: draw.scope })
963
- const unnamed = tree ? [] : kayan.fields.filter((field) => field.kind !== 'join')
964
- const provided = unnamed.filter(owned)
965
- const rest = unnamed.filter((field) => !owned(field))
966
- // Titles of the rows the side card points at, read once for this row. With no `api` each id settles as
967
- // a row the viewer cannot see; the cell never shows the id it holds.
968
- const titles = useRelationshipTitles({ fields: provided, docs: value ? [value] : [], context: { api, client: kayans, locale } })
969
- // A page that draws a row draws no kayan description, except a global's one row, whose page is the
970
- // only place it is drawn.
971
- const about = screen && value?.id !== undefined && !kayan.single ? undefined : descriptionOf(kayan, copy)
972
- const headed = !screen || !!about
973
-
974
- const runOperation =
975
- (operation: PageButton) =>
976
- async (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => {
977
- const landed = await run(() => client.run!(operation.name, input), operation.effect, refuse)
978
- // A kayan's own operation can change anything the row touches, children included, so reload the panels.
979
- if (landed) reload()
980
- return landed
981
- }
982
-
983
- // A relationship is drawn by the list's cell, not a picker: a disabled picker over a row the viewer
984
- // may not choose shows the raw id, where the cell reads the target's title.
985
- const sideEntry = (field: ClientField) => {
986
- if (field.kind !== 'relationship') return control(field.name)
987
- const Cell = cellFor(field)
988
- return (
989
- <div key={field.name} className="grid gap-1.5">
990
- <span className="text-sm font-medium">{labelFor({ kayan, field, copy })}</span>
991
- <span className="text-muted-foreground text-sm">
992
- <Cell kayan={kayan} field={field} value={values[field.name]} t={t} locale={locale} titles={titles} />
993
- </span>
994
- </div>
995
- )
996
- }
997
-
998
- /** The instant this form last read or wrote the row at, for a write whose operation takes it. */
999
- const readAt = (takes?: boolean) => (takes && seed.updatedAt !== undefined ? { expectedUpdatedAt: seed.updatedAt } : {})
1000
- /** Whether a write of the person's own has landed here, which makes a row this page's autosave made theirs. */
1001
- const kept = useRef(false)
1002
- // `refuse` is where a refusal lands: the form's own box by default, or an operation dialog's boxes, which
1003
- // stays open to hold them. Answers whether the write landed.
1004
- const run = async (write: () => Promise<unknown>, effect?: string, refuse: (thrown: unknown) => void = setRefused, status?: DraftStatus): Promise<boolean> => {
1005
- setRefused(undefined)
1006
- setBusy(true)
1007
- try {
1008
- const written = await write()
1009
- kept.current = true
1010
- // What was just written is what the form now holds, so it holds no change: Save goes disabled and
1011
- // the unload guard comes down.
1012
- form.reset(values)
1013
- // The instant the row now stands at, which the next update sends.
1014
- setSeed(seeded(values, status ?? seed.status, (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt))
1015
- noteSave()
1016
- // A write may change the published copy, which View published reads again.
1017
- setPublished(undefined)
1018
- setView('draft')
1019
- onDone(written, effect)
1020
- return true
1021
- } catch (thrown) {
1022
- refusedOver.current = JSON.stringify(values)
1023
- refuse(thrown)
1024
- return false
1025
- } finally {
1026
- setBusy(false)
1027
- }
1028
- }
1029
-
1030
- // Differs from the seed or from what the server holds since; an update sends only these.
1031
- const changed = (field: ClientField) =>
1032
- form.getFieldMeta(field.name)?.isDefaultValue === false || JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name])
1033
-
1034
- const submit = (event: FormEvent<HTMLFormElement>) => {
1035
- event.preventDefault()
1036
- save()
1037
- }
1038
-
1039
- // A draft save holds each value to its kind and none of its rules, so the rules are not checked here either.
1040
- const save = (options?: DraftOptions) => {
1041
- // An update sends only the fields that changed, so two people saving different fields of one row each
1042
- // write their own and neither writes the other's back as it was opened.
1043
- const fields = writable().filter((field) => !id || changed(field))
1044
- const refusal = options?.draft ? undefined : refusalOf({ kayan: kayan.name, fields, values })
1045
- if (refusal) return setRefused(refusal)
1046
- const written = valuesOf({ fields, values, owned })
1047
- const status = drafts && (options?.draft ? afterDraft(seed.status) : 'published')
1048
- void run(() => (id ? client.update({ ...written, ...readAt(expectedUpdatedAt), id }, options) : client.create(written, options)), on, undefined, status)
1049
- }
1050
-
1051
- const writable = () => {
1052
- // A page's form writes only what the page names and draws writable: sending a field it leaves out would
1053
- // overwrite whatever was saved since. A field a control or panel set through `setValue` counts as named.
1054
- const named = tree && new Set([...leavesOf(tree, (leaf) => !leaf.readOnly), ...set.current])
1055
- return (named ? kayan.fields.filter((field) => named.has(field.name)) : kayan.fields).filter((field) => !owned(field))
1056
- }
1057
-
1058
- const status = drafts && seed.status
1059
- // Which copy a changed row shows: the draft, or the published copy, read once when first chosen and drawn read-only with no save or autosave.
1060
- const [view, setView] = useState<'draft' | 'published'>('draft')
1061
- const [published, setPublished] = useState<Record<string, unknown> | null>()
1062
- const viewing = status === 'changed' && view === 'published' && !!published
1063
- const showing = viewing ? seedOf(kayan.fields, published) : values
1064
- const choose = async (next: string) => {
1065
- if (next === 'published' && published === undefined) {
1066
- try {
1067
- setPublished((await client.findByID!({ id })) as Record<string, unknown> | null)
1068
- } catch (thrown) {
1069
- return setRefused(thrown)
1070
- }
1071
- }
1072
- setView(next === 'published' ? 'published' : 'draft')
1073
- }
1074
- /** The values a save was last refused over, which the autosave below never sends again. */
1075
- const refusedOver = useRef<string | undefined>(undefined)
1076
- const [autosaved, setAutosaved] = useState<'saving' | 'saved'>()
1077
- /** Whether the form is still on screen, which an autosave that lands late asks before it moves the host. */
1078
- const alive = useRef(true)
1079
- useEffect(() => {
1080
- alive.current = true
1081
- return () => {
1082
- alive.current = false
1083
- }
1084
- }, [])
1085
- const json = JSON.stringify(values)
1086
- // Autosave a draft of what differs once the form has stayed still for the interval. Never while a save is out,
1087
- // over values a save was refused over, or while a discard question is open: that question says the change is unsaved.
1088
- const [asking, setAsking] = useState(false)
1089
- /** Whether a Discard is on its way out, deleting a draft first where it has one to delete. */
1090
- const [leaving, setLeaving] = useState(false)
1091
- // Nor once a write was refused because the row changed since this form read it: every autosave after it
1092
- // would be refused the same way, so it stops and the refusal stays to say why.
1093
- const stale = shown === 'error.conflict.stale'
1094
- const interval = viewing || asking || blocked || leaving || stale ? undefined : drafts?.autosave
1095
- useEffect(() => {
1096
- if (!interval || busy || autosaved === 'saving' || refusedOver.current === json) return
1097
- const fields = writable()
1098
- if (!fields.some((field) => JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name]))) return
1099
- const timer = setTimeout(() => {
1100
- const sent = valuesOf({ fields: fields.filter((field) => !id || changed(field)), values, owned })
1101
- setAutosaved('saving')
1102
- const words = { draft: true, autosave: true }
1103
- ;(id ? client.update({ ...sent, ...readAt(expectedUpdatedAt), id }, words) : client.create(sent, words)).then(
1104
- (written) => {
1105
- setAutosaved('saved')
1106
- // A refusal a save drew earlier is over: the row just saved fine, so a banner naming no field
1107
- // would sit there with nothing left to say.
1108
- setRefused(undefined)
1109
- refusedOver.current = undefined
1110
- noteSave()
1111
- // The instant the draft now stands at, which the next write sends.
1112
- const updatedAt = (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt
1113
- if (JSON.stringify(form.state.values) === json) {
1114
- form.reset(values)
1115
- setSeed(seeded(values, afterDraft(seed.status), updatedAt))
1116
- } else {
1117
- setSeed((held) => ({ ...held, saved: { ...held.saved, ...sent }, status: afterDraft(held.status), updatedAt }))
1118
- }
1119
- const row = (written as { id?: unknown } | null)?.id
1120
- if (!id && row !== undefined && alive.current) {
1121
- onMade(String(row))
1122
- onDone(written, 'create')
1123
- }
1124
- },
1125
- (thrown: unknown) => {
1126
- refusedOver.current = json
1127
- setAutosaved(undefined)
1128
- setRefused(thrown)
1129
- },
1130
- )
1131
- }, interval)
1132
- return () => clearTimeout(timer)
1133
- }, [interval, busy, autosaved, json, seed, id])
1134
- // Leaving unsaved deletes the untouched draft this page's autosave made, so a cancelled new row leaves none.
1135
- // A published row is kept (a plain read finds it), and so is a draft someone saved since (the delete gets a 409).
1136
- const ownDraft = !!made && id === made && !kept.current && status === 'draft'
1137
- const keepsDraft = ownDraft && operations?.delete !== true
1138
- const out = useRef(false)
1139
- const discard = async (leave: () => void) => {
1140
- if (out.current) return
1141
- out.current = true
1142
- setLeaving(true)
1143
- if (ownDraft && !keepsDraft && client.findByID) {
1144
- const found = await client.findByID({ id }).then(
1145
- (row) => (row ? 'published' : 'absent'),
1146
- (thrown: unknown) => ((thrown as { status?: unknown } | null)?.status === 404 ? 'absent' : 'unknown'),
1147
- )
1148
- if (found === 'absent') await client.delete({ id, ...readAt(expectedUpdatedAtOn?.includes('delete')) }).catch((thrown: unknown) => console.error('The draft this page made could not be deleted:', thrown))
1149
- }
1150
- // The edit is thrown away before the navigation, so a slow navigation leaves no change for the autosave to save.
1151
- form.reset(seed.saved)
1152
- out.current = false
1153
- setLeaving(false)
1154
- setAsking(false)
1155
- leave()
1156
- }
1157
- useEffect(() => {
1158
- if (autosaved !== 'saved') return
1159
- const timer = setTimeout(() => setAutosaved(undefined), 2000)
1160
- return () => clearTimeout(timer)
1161
- }, [autosaved, saves])
1162
-
1163
- const buttonOf = (operation: PageButton) => {
1164
- const Ref = refOf({ screen, place: 'button', path: operation.component })
1165
- if (Ref && screen) {
1166
- return (
1167
- <OwnButton
1168
- key={operation.name}
1169
- Own={Ref}
1170
- operation={operation}
1171
- busy={busy}
1172
- copy={copy}
1173
- call={async (input) => {
1174
- await runOperation(operation)({ ...input, id }, setRefused)
1175
- }}
1176
- />
1177
- )
1178
- }
1179
- // A delete is the one write nothing brings back, so its button is always the two-step dialog, whatever the node carries.
1180
- if (operation.effect === 'delete') {
1181
- return (
1182
- <DeleteButton
1183
- key={operation.name}
1184
- busy={busy}
1185
- copy={copy}
1186
- kayan={kayan}
1187
- label={operationLabelFor({ kayan, operation, copy })}
1188
- row={value ?? undefined}
1189
- onDelete={(refuse) => run(() => client.delete({ id }), 'delete', refuse)}
1190
- />
1191
- )
1192
- }
1193
- // A button that asks for no values and carries a confirm asks it in the app's own dialog; any other opens the
1194
- // registry's dialog, which asks the confirm in its heading. Each names the row it runs on.
1195
- const description = value ? rowWarning({ kayan, row: value, operation: operation.name, copy }) : undefined
1196
- if (operation.confirm && !operation.fields?.length) {
1197
- return <NodeButton key={operation.name} node={operation} copy={copy} busy={busy} description={description} run={() => void runOperation(operation)({ id }, setRefused)} />
1198
- }
1199
- return (
1200
- <OperationButton
1201
- key={operation.name}
1202
- api={api}
1203
- busy={busy}
1204
- copy={copy}
1205
- description={description}
1206
- inputOf={(values) => ({ id, ...values })}
1207
- kayan={kayan}
1208
- kayans={kayans}
1209
- operation={operation}
1210
- run={runOperation(operation)}
1211
- />
1212
- )
1213
- }
1214
- // Below sm the action bar is fixed, so the document keeps its height free as padding and scroll padding
1215
- // (the document is what scrolls), and a field reached with Tab is never under the bar.
1216
- const measureBar = useCallback((bar: HTMLElement | null) => {
1217
- // Inside a sheet or a dialog the bar is the popup's own, so the document keeps no room for it.
1218
- if (!bar || bar.closest('[role=dialog]')) return
1219
- const root = document.documentElement.style
1220
- // `--action-bar-height` too, which a list's own bar on a phone stands on rather than covering this one.
1221
- const keep = (height: number) => {
1222
- for (const property of ['padding-bottom', 'scroll-padding-bottom', '--action-bar-height']) {
1223
- if (height) root.setProperty(property, `${height}px`)
1224
- else root.removeProperty(property)
1225
- }
1226
- }
1227
- // `borderBoxSize` is missing before Safari 15.4 and one box rather than a
1228
- // list before Firefox 92, so the bar's own `offsetHeight` stands in there.
1229
- const observer = new ResizeObserver(([entry]) => keep(entry ? (entry.borderBoxSize?.[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight) : 0))
1230
- observer.observe(bar)
1231
- return () => {
1232
- observer.disconnect()
1233
- keep(0)
1234
- }
1235
- }, [])
1236
- const updated = id ? (savedAt ?? (value?.updatedAt ? new Date(value.updatedAt as string | Date) : undefined)) : undefined
1237
- // The draft or the published copy, on a changed row only. Beside the actions from xl up; below xl a row at
1238
- // the top of the form (`chrome`), so it is never in the phone's fixed bar.
1239
- const viewSwitch = (className: string, phone = false) =>
1240
- status === 'changed' && client.findByID ? (
1241
- <ToggleGroup
1242
- data-view-switch=""
1243
- value={[viewing ? 'published' : 'draft']}
1244
- onValueChange={(next) => next[0] && void choose(next[0])}
1245
- aria-label={t('version.shownCopy', 'Version shown')}
1246
- className={`bg-muted flex shrink-0 rounded-md p-0.5 ${className}`}
1247
- >
1248
- {(['draft', 'published'] as const).map((one) => (
1249
- <ToggleGroupItem key={one} value={one} className={`data-pressed:bg-background border-0 px-2 py-0.5 text-xs data-pressed:shadow-sm${phone ? ' min-h-11 flex-1' : ''}`}>
1250
- {t(`version.${one}`, { draft: 'Draft', published: 'Published' }[one])}
1251
- </ToggleGroupItem>
1252
- ))}
1253
- </ToggleGroup>
1254
- ) : null
1255
- // A submit button in a controls node sits outside the <form>, so every submit names it by its id. The spacing is
1256
- // logical, so the row mirrors in Arabic.
1257
- const actionsOf = (header: boolean) => {
1258
- const size = header ? 'sm' : 'default'
1259
- const tall = header ? 'max-sm:h-11' : ''
1260
- const actions = (
1261
- <>
1262
- {/* A refusal naming no field, drawn here and not in the form: the form sits in the Details panel, and a
1263
- Publish pressed from another tab would be refused where nobody is looking. */}
1264
- {header && shown ? (
1265
- <p role="alert" className="text-destructive line-clamp-2 min-w-24 flex-1 text-sm max-sm:basis-full sm:-order-1">
1266
- {banner}
1267
- </p>
1268
- ) : null}
1269
- {status && !header ? (
1270
- <span data-status={status} className={`me-auto rounded-md px-2 py-0.5 text-sm ${pills[status]}`}>
1271
- {t('version.status', 'Status')}: {t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}
1272
- </span>
1273
- ) : null}
1274
- {/* Below xl it leaves the header for the top of the form (`chrome`). */}
1275
- {viewSwitch(header ? 'max-xl:hidden' : '')}
1276
- {autosaved ? (
1277
- <span
1278
- role="status"
1279
- data-autosave={autosaved}
1280
- className={autosaved === 'saved'
1281
- ? 'animate-out fade-out fill-mode-forwards delay-1500 animation-duration-500 flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400'
1282
- : 'text-muted-foreground text-sm'}
1283
- >
1284
- {autosaved === 'saved' ? <Check aria-hidden className="size-4" /> : null}
1285
- {autosaved === 'saving' ? t('version.saving', 'Saving…') : t('version.saved', 'Saved')}
1286
- </span>
1287
- ) : null}
1288
- {/* Read off the browser's clock, so it can differ from the server's render. */}
1289
- {header && updated && !Number.isNaN(updated.getTime()) ? (
1290
- <span data-updated="" className="text-muted-foreground text-sm max-xl:hidden" suppressHydrationWarning>
1291
- <UpdatedAgo at={updated} t={t} locale={locale} />
1292
- </span>
1293
- ) : null}
1294
- {/* A dirty form asks before Cancel: the router's navigation raises no `beforeunload`, so the browser's
1295
- guard does not see it. A clean form's Cancel is a plain button. */}
1296
- {onCancel ? (
1297
- <Button
1298
- type="button"
1299
- variant="outline"
1300
- size={size}
1301
- className={tall}
1302
- disabled={busy}
1303
- aria-haspopup={dirty ? 'dialog' : undefined}
1304
- onClick={dirty ? () => setAsking(true) : onCancel}
1305
- >
1306
- {t('admin.cancel', 'Cancel') ?? 'Cancel'}
1307
- </Button>
1308
- ) : null}
1309
- {viewing ? null : drafts ? (
1310
- <>
1311
- {/* Save draft waits for a change on an update and is always offered beside the autosave. Publish is
1312
- offered while anything is unpublished, with no change of its own. */}
1313
- <Button type="button" size={size} className={`${draftColour} ${tall}`} data-button="save-draft" disabled={busy || autosaved === 'saving' || (!!id && !dirty)} onClick={() => save({ draft: true })}>
1314
- {t('version.saveDraft', 'Save draft')}
1315
- </Button>
1316
- <Button type="submit" form={formId} size={size} className={`${publishColour} ${tall}`} data-button="publish" disabled={busy || autosaved === 'saving' || (!dirty && status === 'published')}>
1317
- {busy ? <Spinner /> : null}
1318
- {t('version.publishChanges', 'Publish changes')}
1319
- </Button>
1320
- </>
1321
- ) : (
1322
- // Waits for a change on an update alone: a create is pressed to be checked.
1323
- <Button type="submit" form={formId} size={size} className={tall} disabled={busy || (on === 'update' && !dirty)}>
1324
- {busy ? <Spinner /> : null}
1325
- {t('admin.save', 'Save')}
1326
- </Button>
1327
- )}
1328
- </>
1329
- )
1330
- // `flex-wrap`: a row that cannot wrap is as wide as all its buttons together, which widens every grid
1331
- // and flex box above it past a phone's width.
1332
- if (!header) return <div className="flex flex-wrap items-center justify-end gap-2 rtl:justify-start">{actions}</div>
1333
- // Below sm the header keeps the trigger, title and pill, and the rest moves to a bar fixed to the bottom of the
1334
- // screen; from sm up the bar is `contents`. Inside a sheet or dialog the row sticks to the popup's foot instead.
1335
- return (
1336
- <div className="ms-auto flex min-w-min items-center justify-end gap-2 sm:flex-wrap max-sm:in-[[role=dialog]]:bg-background max-sm:in-[[role=dialog]]:sticky max-sm:in-[[role=dialog]]:bottom-0 max-sm:in-[[role=dialog]]:z-20 max-sm:in-[[role=dialog]]:order-last max-sm:in-[[role=dialog]]:w-full max-sm:in-[[role=dialog]]:border-t">
1337
- {/* Coloured by status and always carrying its word, never the colour alone. The row shrinks no further
1338
- than the narrowest it can be drawn, so the title beside it wraps rather than sitting under it. */}
1339
- {status ? (
1340
- <Badge data-status={status} className={pills[status]} title={t('version.status', 'Status')}>
1341
- {t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}
1342
- </Badge>
1343
- ) : null}
1344
- <div
1345
- data-action-bar=""
1346
- ref={measureBar}
1347
- className="max-sm:bg-background max-sm:fixed max-sm:in-[[role=dialog]]:static max-sm:in-[[role=dialog]]:flex-1 max-sm:in-[[role=dialog]]:border-t-0 max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:flex max-sm:flex-wrap max-sm:items-center max-sm:justify-end max-sm:gap-2 max-sm:border-t max-sm:px-4 max-sm:pt-2 max-sm:pb-[calc(0.5rem+env(safe-area-inset-bottom))] sm:contents"
1348
- >
1349
- {actions}
1350
- </div>
1351
- </div>
1352
- )
1353
- }
1354
- // On a screen's page the title is the page's own `title` node, so the form keeps only the sentence under it.
1355
- const chrome = (
1356
- <>
1357
- {/* On a screen's page a kayan with no sentence draws no header and no rule under it. */}
1358
- {headed ? (
1359
- <header className="max-w-3xl">
1360
- {screen ? null : <h1 className="text-2xl font-bold tracking-tight">{titleOf({ kayan, copy })}</h1>}
1361
- {about ? <p className="text-muted-foreground mt-1 text-sm">{about}</p> : null}
1362
- </header>
1363
- ) : null}
1364
- {shown && !controlled ? (
1365
- <p role="alert" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
1366
- {banner}
1367
- </p>
1368
- ) : null}
1369
- {controlled ? viewSwitch('xl:hidden', true) : null}
1370
- {status === 'changed' && viewing ? (
1371
- <p data-draft-banner="published" className="bg-muted text-muted-foreground rounded-md border px-3 py-1.5 text-sm">
1372
- {t('version.readOnlyPublished', 'Read only. This is the published version.')}
1373
- </p>
1374
- ) : status === 'changed' ? (
1375
- <div data-draft-banner="draft" className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-1.5 text-sm text-amber-900 dark:text-amber-200">
1376
- <span className="me-auto">{t('version.editingDraft', 'You are editing a draft. The published version is different.')}</span>
1377
- {client.findByID ? (
1378
- <Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => void choose('published')}>
1379
- {t('version.viewPublished', 'View published')}
1380
- </Button>
1381
- ) : null}
1382
- {/* Restores the row's own published copy, which needs both keys a restore needs. */}
1383
- {operations?.restoreVersion === true && client.run ? (
1384
- <AlertDialog>
1385
- <AlertDialogTrigger
1386
- render={
1387
- <Button type="button" variant="link" size="sm" className="text-destructive h-auto p-0" disabled={busy || autosaved === 'saving'}>
1388
- {t('admin.discard', 'Discard changes')}
1389
- </Button>
1390
- }
1391
- />
1392
- <AlertDialogContent>
1393
- <AlertDialogHeader>
1394
- <AlertDialogTitle>{t('version.confirmRevertToSaved', 'Confirm revert to saved')}</AlertDialogTitle>
1395
- <AlertDialogDescription>
1396
- {t('version.aboutToRevertToPublished', 'You are about to revert this document\'s changes to its published state. Are you sure?')}
1397
- </AlertDialogDescription>
1398
- </AlertDialogHeader>
1399
- <AlertDialogFooter className="max-sm:[&_button]:h-11">
1400
- <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>
1401
- <AlertDialogAction variant="destructive" data-button="confirm-discard-draft" disabled={busy || autosaved === 'saving'} onClick={() => void run(() => client.run!('restoreVersion', { id, ...readAt(expectedUpdatedAtOn?.includes('restoreVersion')) }), 'update', undefined, 'published')}>
1402
- {t('admin.discard', 'Discard changes')}
1403
- </AlertDialogAction>
1404
- </AlertDialogFooter>
1405
- </AlertDialogContent>
1406
- </AlertDialog>
1407
- ) : null}
1408
- </div>
1409
- ) : null}
1410
- </>
1411
- )
1412
- return (
1413
- // The provider is around the whole screen, not just the column of inputs, so a panel the project
1414
- // registered binds a field of this form through `useKayanField` wherever the layout put it.
1415
- <KayanForm.Provider value={{ check, forget, form, issues: sentences, locales: localesOf(held), on, writes }}>
1416
- <FormContext value={{ getData: () => showing, setValue, disabled: busy || viewing, saves, expectedUpdatedAtOn }}>
1417
- {/* One question for both ways out, Cancel and a blocked navigation,
1418
- kept mounted so nothing but an answer closes it. */}
1419
- <DiscardDialog
1420
- t={t}
1421
- busy={busy || autosaved === 'saving' || leaving}
1422
- open={asking || !!blocked}
1423
- note={keepsDraft ? (t('admin.discardKeepsDraft', 'The draft saved so far is kept.') ?? 'The draft saved so far is kept.') : undefined}
1424
- onOpenChange={(open) => {
1425
- if (open || leaving) return
1426
- setAsking(false)
1427
- blocked?.reset()
1428
- }}
1429
- onDiscard={() => void discard(blocked ? blocked.proceed : onCancel ?? (() => {}))}
1430
- />
1431
- {nodes || screen ? (
1432
- <div className="flex flex-1 flex-col gap-6">{nodes ? walk([{ form: [...nodes] }]) : pageOf(screen!, draw)}</div>
1433
- ) : (
1434
- <form id={formId} noValidate onSubmit={submit} className="flex flex-1 flex-col gap-6">
1435
- {chrome}
1436
- <div className="border-t pt-6">
1437
- <div className={provided.length ? 'grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]' : 'max-w-3xl'}>
1438
- {/* A div and not a `main`: the page's shell already holds the one main landmark. */}
1439
- <div data-fields="" className="grid min-w-0 gap-4">
1440
- <Card className="overflow-visible">
1441
- <CardContent className="flex flex-col gap-4">
1442
- {rest.length ? <div className="flex flex-col gap-4">{rest.map((field) => control(field.name))}</div> : null}
1443
- {actionsOf(false)}
1444
- </CardContent>
1445
- </Card>
1446
- </div>
1447
- {provided.length ? (
1448
- <aside className="grid h-fit gap-4 lg:sticky lg:top-20">
1449
- <Card className="overflow-visible rounded-lg">
1450
- <CardHeader>
1451
- <CardTitle>
1452
- <h2 className="text-base font-medium">{t('admin.record', 'Record') ?? 'Record'}</h2>
1453
- </CardTitle>
1454
- </CardHeader>
1455
- <CardContent className="grid gap-4">{provided.map(sideEntry)}</CardContent>
1456
- </Card>
1457
- </aside>
1458
- ) : null}
1459
- </div>
1460
- </div>
1461
- </form>
1462
- )}
1463
- </FormContext>
1464
- </KayanForm.Provider>
1465
- )
1466
- }
1467
- // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 6a3950dbcb0d13b0f3d3943056ec08b120e52ccaf71877c2bf0e29a7823bbbdc