create-mithaq 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/payload.txt +22 -0
  3. package/LICENSES/ponytail.txt +21 -0
  4. package/index.js +127 -0
  5. package/package.json +19 -4
  6. package/template/apps/asas-platform/components.json +25 -0
  7. package/template/apps/asas-platform/gitignore +3 -0
  8. package/template/apps/asas-platform/package.json +55 -0
  9. package/template/apps/asas-platform/public/favicon.svg +1 -0
  10. package/template/apps/asas-platform/src/api.ts +147 -0
  11. package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
  12. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
  13. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
  14. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
  15. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
  16. package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
  17. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
  18. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
  19. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
  20. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
  21. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
  22. package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
  23. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
  24. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
  25. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
  26. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
  27. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
  28. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
  29. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
  30. package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
  31. package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
  32. package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
  33. package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
  34. package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
  35. package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
  36. package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
  37. package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
  38. package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
  39. package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
  40. package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
  41. package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
  42. package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
  43. package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
  44. package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
  45. package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
  46. package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
  47. package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
  48. package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
  49. package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
  50. package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
  51. package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
  52. package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
  53. package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
  54. package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
  55. package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
  56. package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
  57. package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
  58. package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
  59. package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
  60. package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
  61. package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
  62. package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
  63. package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
  64. package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
  65. package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
  66. package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
  67. package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
  68. package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
  69. package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
  70. package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
  71. package/template/apps/asas-platform/src/copy.ts +1 -0
  72. package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
  73. package/template/apps/asas-platform/src/lib/utils.ts +1 -0
  74. package/template/apps/asas-platform/src/links.ts +38 -0
  75. package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
  76. package/template/apps/asas-platform/src/redirect.ts +1 -0
  77. package/template/apps/asas-platform/src/request-headers.ts +11 -0
  78. package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
  79. package/template/apps/asas-platform/src/router.tsx +7 -0
  80. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
  81. package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
  82. package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
  83. package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
  84. package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
  85. package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
  86. package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
  87. package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
  88. package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
  89. package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
  90. package/template/apps/asas-platform/src/screen-form.tsx +34 -0
  91. package/template/apps/asas-platform/src/screen-list.tsx +139 -0
  92. package/template/apps/asas-platform/src/screen-names.ts +10 -0
  93. package/template/apps/asas-platform/src/screens.tsx +868 -0
  94. package/template/apps/asas-platform/src/server.ts +20 -0
  95. package/template/apps/asas-platform/src/slide.ts +22 -0
  96. package/template/apps/asas-platform/src/start.ts +42 -0
  97. package/template/apps/asas-platform/src/styles.css +200 -0
  98. package/template/apps/asas-platform/src/theme.ts +2 -0
  99. package/template/apps/asas-platform/tsconfig.json +19 -0
  100. package/template/apps/asas-platform/vite.config.ts +26 -0
  101. package/template/apps/asas-tanstack/components.json +25 -0
  102. package/template/apps/asas-tanstack/gitignore +3 -0
  103. package/template/apps/asas-tanstack/package.json +56 -0
  104. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  105. package/template/apps/asas-tanstack/src/api.ts +147 -0
  106. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  107. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
  108. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
  109. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
  110. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
  111. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
  112. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
  113. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
  114. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
  115. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
  116. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
  117. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
  118. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
  119. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
  120. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
  121. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
  122. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
  123. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
  124. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
  125. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  126. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  127. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  128. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  129. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  130. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  131. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  132. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  133. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  134. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  135. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  136. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  137. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  138. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  139. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  140. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  141. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
  142. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  143. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  144. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  145. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  146. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  147. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  148. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  149. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  150. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  151. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  152. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  153. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  154. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  155. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  156. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  157. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  158. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  159. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  160. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  161. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  162. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  163. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  164. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  165. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  166. package/template/apps/asas-tanstack/src/copy.ts +870 -0
  167. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  168. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  169. package/template/apps/asas-tanstack/src/links.ts +38 -0
  170. package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
  171. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  172. package/template/apps/asas-tanstack/src/redirect.ts +20 -0
  173. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  174. package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
  175. package/template/apps/asas-tanstack/src/router.tsx +7 -0
  176. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
  177. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
  178. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
  179. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  180. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  181. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  182. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
  183. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
  184. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
  185. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  186. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  187. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  188. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  189. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  190. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
  191. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  192. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  193. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
  194. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
  195. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
  196. package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
  197. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
  198. package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
  199. package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
  200. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  201. package/template/apps/asas-tanstack/src/screens.tsx +868 -0
  202. package/template/apps/asas-tanstack/src/server.ts +16 -0
  203. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  204. package/template/apps/asas-tanstack/src/start.ts +57 -0
  205. package/template/apps/asas-tanstack/src/styles.css +159 -0
  206. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  207. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  208. package/template/apps/asas-tanstack/vite.config.ts +28 -0
  209. package/template/package.json +16 -0
  210. package/template/templates/asas/.env.example +16 -0
  211. package/template/templates/asas/components.json +22 -0
  212. package/template/templates/asas/mithaq.hints.json +25 -0
  213. package/template/templates/asas/package.json +38 -0
  214. package/template/templates/asas/src/asas.ts +93 -0
  215. package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
  216. package/template/templates/asas/src/audit-log/sink.ts +89 -0
  217. package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
  218. package/template/templates/asas/src/courses/courses.ts +612 -0
  219. package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
  220. package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
  221. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
  222. package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
  223. package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
  224. package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
  225. package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
  226. package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
  227. package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
  228. package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
  229. package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
  230. package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
  231. package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
  232. package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
  233. package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
  234. package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
  235. package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
  236. package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
  237. package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
  238. package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
  239. package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
  240. package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
  241. package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
  242. package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
  243. package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
  244. package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
  245. package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
  246. package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
  247. package/template/templates/asas/src/local-users/local-users.ts +143 -0
  248. package/template/templates/asas/src/media/media.ts +137 -0
  249. package/template/templates/asas/src/media/ui/preview.tsx +143 -0
  250. package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
  251. package/template/templates/asas/src/messages/account.json +106 -0
  252. package/template/templates/asas/src/messages/auditLog.json +186 -0
  253. package/template/templates/asas/src/messages/courses.json +270 -0
  254. package/template/templates/asas/src/messages/enrollments.json +42 -0
  255. package/template/templates/asas/src/messages/invitation.json +66 -0
  256. package/template/templates/asas/src/messages/localUsers.json +42 -0
  257. package/template/templates/asas/src/messages/media.json +66 -0
  258. package/template/templates/asas/src/messages/member.json +42 -0
  259. package/template/templates/asas/src/messages/memberRoles.json +42 -0
  260. package/template/templates/asas/src/messages/mithaq.json +170 -0
  261. package/template/templates/asas/src/messages/organization.json +50 -0
  262. package/template/templates/asas/src/messages/organizationRole.json +50 -0
  263. package/template/templates/asas/src/messages/roles.json +58 -0
  264. package/template/templates/asas/src/messages/session.json +82 -0
  265. package/template/templates/asas/src/messages/sessions.json +106 -0
  266. package/template/templates/asas/src/messages/settings.json +66 -0
  267. package/template/templates/asas/src/messages/tags.json +38 -0
  268. package/template/templates/asas/src/messages/user.json +90 -0
  269. package/template/templates/asas/src/messages/verification.json +50 -0
  270. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  271. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  272. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  273. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  274. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  275. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  276. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  277. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  278. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  279. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  280. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  281. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  282. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  283. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  284. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  285. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  286. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  287. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  288. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  289. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  290. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  291. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  292. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  293. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  294. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  295. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  296. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  297. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  298. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  299. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  300. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  301. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  302. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  303. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  304. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  305. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  306. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  307. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  308. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  309. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  310. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  311. package/template/templates/asas/src/migrations/schema.ts +29 -0
  312. package/template/templates/asas/src/mithaq.ts +406 -0
  313. package/template/templates/asas/src/pages.ts +74 -0
  314. package/template/templates/asas/src/platform-http.ts +96 -0
  315. package/template/templates/asas/src/rich-text-nodes.ts +11 -0
  316. package/template/templates/asas/src/roles/grants.ts +47 -0
  317. package/template/templates/asas/src/roles/roles.ts +184 -0
  318. package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
  319. package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
  320. package/template/templates/asas/src/roles.ts +16 -0
  321. package/template/templates/asas/src/seed.ts +325 -0
  322. package/template/templates/asas/src/sessions/sessions.ts +155 -0
  323. package/template/templates/asas/src/settings/settings.ts +69 -0
  324. package/template/templates/asas/src/statements.ts +11 -0
  325. package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
  326. package/template/templates/asas/src/storage/defaults.ts +156 -0
  327. package/template/templates/asas/src/storage/index.ts +95 -0
  328. package/template/templates/asas/src/storage/local.ts +143 -0
  329. package/template/templates/asas/src/storage/r2.ts +5 -0
  330. package/template/templates/asas/src/storage/s3.ts +91 -0
  331. package/template/templates/asas/src/storage/uploadthing.ts +59 -0
  332. package/template/templates/asas/src/tags/tags.ts +80 -0
  333. package/template/templates/asas/src/tenant-access.ts +221 -0
  334. package/template/templates/asas/src/tenant-roles.ts +262 -0
  335. package/template/templates/asas/src/ui/app-shell.tsx +260 -0
  336. package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
  337. package/template/templates/asas/src/ui/record-page.tsx +50 -0
  338. package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
  339. package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
  340. package/template/templates/asas/src/ui/session-labels.tsx +36 -0
  341. package/template/templates/asas/src/ui/table-page.tsx +9 -0
  342. package/template/templates/asas/src/ui/top-shell.tsx +60 -0
  343. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
  344. package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
  345. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  346. package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
  347. package/template/templates/asas/src/ui-platform/home.tsx +63 -0
  348. package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
  349. package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
  350. package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
  351. package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
  352. package/template/templates/asas/src/ui-portal/home.tsx +77 -0
  353. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  354. package/template/templates/auth/package.json +14 -0
  355. package/template/templates/auth/src/index.ts +690 -0
  356. package/template/templates/auth/src/platform-roles.ts +87 -0
  357. package/README.md +0 -3
@@ -0,0 +1,868 @@
1
+ import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'
2
+ import { notFound, redirect, useMatches, useRouter } from '@tanstack/react-router'
3
+ import { createIsomorphicFn, createServerFn, createServerOnlyFn } from '@tanstack/react-start'
4
+ import { Suspense, lazy, useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react'
5
+ import { holds, navOf, referencesIn, type AccessReport, type ClientKayan, type LoaderTools, type Row, type ScreenClient } from 'mithaq/admin'
6
+ import { ScreenContext, useScreen, type ScreenState } from 'mithaq/admin/react'
7
+ import { actingAs, arm, authArm, caused, clientOf, readsOf, takesKey } from './api.ts'
8
+ import { clampedHref, routedHref } from './links.ts'
9
+ import { mergedHeaders } from './request-headers.ts'
10
+ import { documentHead, localeOf, locales, translator, type Locale } from './copy.ts'
11
+ import { FieldValue, type FormClient, type FormProps } from './components/mithaq/form.tsx'
12
+ import type { Page } from './components/mithaq/list.tsx'
13
+ import { QueryRefused, findOf, listRaw, prefixOf, readQuery, rulesOf, scopedTo, withList, type RawQuery } from './components/mithaq/list-query.ts'
14
+ import { createsIn, newTitleOf, titleOf } from './components/mithaq/controls.tsx'
15
+ import { NavLinks, NodeButton, OwnButton, RecordFrame, formIn, holdFocus, frameOf, listIn, nodesOf, pageButtonOf, pageOf, readsAsValues, refOf, relatedIn, rowTitle, scopeOf, type Draw, type Refs, type TreePage } from './components/mithaq/nodes.tsx'
16
+ import { idsOf } from './components/mithaq/relationship-control.tsx'
17
+ import { ScreenForm } from './screen-form.tsx'
18
+ import { ScreenList, askedFor, readsDrafts, rowReadsDrafts, rowsKey } from './screen-list.tsx'
19
+ import { screenNamed } from './screen-names.ts'
20
+ import { slide } from './slide.ts'
21
+ import { useCopy } from './routes/__root.tsx'
22
+
23
+ /**
24
+ * The built asas, on the server alone. The module cache serves one build for
25
+ * every call, since `asas` stays out of the server bundle; a changed `asas` takes a restart.
26
+ */
27
+ export const getAsas = createServerOnlyFn(async () => (await import('asas')).asas)
28
+
29
+ type InProcess = Record<string, { operations: Record<string, (input: object, options: object) => Promise<unknown>> }>
30
+
31
+ /**
32
+ * Each kayan's operations in process. In the platform app a read of a kayan asks `tenant: 'all'`,
33
+ * which the runner grants platform staff there alone, so staff read every organization's rows; a
34
+ * global is one organization's row and asks for none, and a write never asks.
35
+ */
36
+ const inProcess = async (asas: Awaited<ReturnType<typeof getAsas>>): Promise<InProcess> => {
37
+ const kayan = asas.kayan as unknown as InProcess
38
+ if (!(await import('asas')).onPlatform()) return kayan
39
+ const reads = new Set(asas.operations.filter((one) => one.effect === 'read' && !one.single).map((one) => `${one.kayan}/${one.name}`))
40
+ return Object.fromEntries(Object.entries(kayan).map(([name, one]) => [name, {
41
+ operations: Object.fromEntries(Object.entries(one.operations).map(([operation, run]) =>
42
+ [operation, reads.has(`${name}/${operation}`) ? (input: object, options: object) => run(input, { ...options, tenant: 'all' }) : run])),
43
+ }]))
44
+ }
45
+
46
+ /** One screen's generated glob: each file its references name, keyed by the path a reference prints, loaded only when called. */
47
+ export type Files = Readonly<Record<string, () => Promise<unknown>>>
48
+
49
+ type Drawn = ComponentType<Record<string, unknown>>
50
+ const loaded = new Map<string, Drawn>()
51
+ const drawn = new Map<string, Drawn>()
52
+
53
+ const load = async (files: Files, reference: string) => {
54
+ const at = reference.indexOf('#')
55
+ const exported = ((await files[reference.slice(0, at)]?.()) as Record<string, Drawn> | undefined)?.[reference.slice(at + 1)]
56
+ if (exported) loaded.set(reference, exported)
57
+ return exported
58
+ }
59
+
60
+ // One `lazy` per reference. A loaded file is handed over as a thenable that
61
+ // resolves at once, which React reads without suspending; an unloaded one
62
+ // (hydration before any loader ran) suspends.
63
+ const drawnOf = (files: Files, reference: string) => {
64
+ let found = drawn.get(reference)
65
+ if (!found) {
66
+ found = lazy(() => {
67
+ const ready = loaded.get(reference)
68
+ return ready
69
+ ? ({ then: (resolve: (module: { default: Drawn }) => void) => resolve({ default: ready }) } as unknown as Promise<{ default: Drawn }>)
70
+ : load(files, reference).then((exported) => ({ default: exported ?? (() => null) }))
71
+ })
72
+ drawn.set(reference, found)
73
+ }
74
+ return found
75
+ }
76
+
77
+ const refsOf = (files: Files, screen: ScreenClient): Refs =>
78
+ Object.fromEntries(
79
+ referencesIn(screen)
80
+ .filter((reference) => Object.hasOwn(files, reference.slice(0, reference.indexOf('#'))))
81
+ .map((reference) => [reference, drawnOf(files, reference)]),
82
+ ) as Refs
83
+
84
+ const preload = (files: Files, ...parts: unknown[]) => Promise.all(referencesIn(parts).map((reference) => loaded.get(reference) ?? load(files, reference)))
85
+
86
+ /** This build's screens by name: what a request's screen name is checked against before anything is read by it. */
87
+ const screensOf = (targets: unknown) =>
88
+ Object.fromEntries(Object.entries(targets as Record<string, { capability?: string } | undefined>).filter(([, target]) => target?.capability === 'screen'))
89
+
90
+ const segments = (path: string) => path.split('/').filter(Boolean)
91
+
92
+ /**
93
+ * The page a path asks for, a kayan's or the screen's own, longest first and a
94
+ * static segment before a parameter, so `/courses/new` is the create page, not
95
+ * an edit of a row called `new`.
96
+ */
97
+ export const matchPage = (screen: ScreenClient, path: string) => {
98
+ const asked = segments(path)
99
+ const pages = [
100
+ ...Object.entries(screen.pages).map(([name, page]) => ({ kayan: undefined, name, page, parts: segments(page.path) })),
101
+ ...Object.values(screen.kayan).flatMap((kayan) => Object.entries(kayan.pages).map(([name, page]) => ({ kayan: kayan.name as string | undefined, name, page, parts: segments(page.path) }))),
102
+ ]
103
+ .sort((a, b) => b.parts.length - a.parts.length || a.parts.filter((one) => one.startsWith(':')).length - b.parts.filter((one) => one.startsWith(':')).length)
104
+ for (const one of pages) {
105
+ if (one.parts.length !== asked.length) continue
106
+ const params: Record<string, string> = {}
107
+ if (one.parts.every((part, at) => (part.startsWith(':') ? ((params[part.slice(1)] = asked[at]!), true) : part === asked[at]))) {
108
+ return { kayan: one.kayan, page: one.name, params }
109
+ }
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ type Actor = {
115
+ readonly id?: string
116
+ readonly name?: string | null
117
+ readonly email?: string | null
118
+ readonly impersonatedBy?: unknown
119
+ readonly activeOrganizationId?: string | null
120
+ readonly organizations?: readonly { readonly id: string; readonly name: string; readonly slug?: string }[]
121
+ }
122
+
123
+ // `'unauthorized'` is answered before the projection is read, so a refused screen sends no kayan, field, row or page;
124
+ // what is returned is serialized into the document, so projection and report are cut to what the actor may read.
125
+ const resolveScreen = async (asked: unknown) => {
126
+ const [{ getRequest, setResponseHeader }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
127
+ const name = screenNamed(screensOf(asas.targets), asked)
128
+ const request = getRequest()
129
+ const ask = (path: string) => asas.handler(new Request(new URL(path, request.url), { headers: request.headers }))
130
+ await slide({ strategies: asas.auth.strategies, headers: request.headers, setResponseHeader })
131
+ // Off the strategy loop, not the provider's session route: in process, so
132
+ // no request counts against its rate limit.
133
+ const actor = (await asas.auth.actor(request.headers)) as Actor | null
134
+ if (!actor?.email) return undefined
135
+ const report = (await (await ask('/api/access')).json()) as AccessReport
136
+ if (!report.screens?.includes(name)) return 'unauthorized' as const
137
+ const full = (asas.targets as unknown as Record<string, { client?: ScreenClient } | undefined>)[name]!.client!
138
+ const kayan = Object.fromEntries(
139
+ Object.entries(full.kayan)
140
+ .filter(([one]) => report.collections?.[one]?.read === true)
141
+ .map(([one, value]) => [one, { ...value, fields: value.fields.filter((field) => report.collections?.[one]?.fields?.[field.name]?.read === true) }]),
142
+ )
143
+ const { onPlatform, platformEntry, PLATFORM_SCREEN } = await import('asas')
144
+ const platform = platformEntry(actor)
145
+ // The other screens of this app the actor may enter. The platform's own name
146
+ // is left out of a tenant app's list, so the one link to it is `platformEntry`'s alone.
147
+ const served = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
148
+ const siblings = (report.screens ?? [])
149
+ .filter((one) => one !== name && Object.hasOwn(served, one) && (one === PLATFORM_SCREEN) === onPlatform())
150
+ .map((one) => ({ name: one, path: served[one]!.client.path }))
151
+ const others = [...siblings, ...(platform ? [{ name: PLATFORM_SCREEN, path: platform }] : [])]
152
+ return {
153
+ asas,
154
+ request,
155
+ name,
156
+ report,
157
+ // Whole, so a page of a kayan the cut leaves out is told from a path naming no page.
158
+ full,
159
+ user: {
160
+ ...(actor.id ? { id: actor.id } : {}),
161
+ name: actor.name ?? actor.email,
162
+ email: actor.email,
163
+ // Only the fact; the id of the person behind the session stays on the server.
164
+ ...(actor.impersonatedBy ? { impersonating: true } : {}),
165
+ ...(others.length ? { screens: others } : {}),
166
+ ...(actor.organizations && {
167
+ organizations: actor.organizations.map(({ id, name, slug }) => ({ id, name, slug })),
168
+ activeOrganizationId: actor.activeOrganizationId ?? null,
169
+ }),
170
+ },
171
+ access: {
172
+ // So `holds` answers a `screen.<name>` key in the browser as on the server.
173
+ ...(report.screens === undefined ? {} : { screens: report.screens }),
174
+ collections: Object.fromEntries(Object.keys(kayan).map((one) => [one, report.collections![one]])),
175
+ // For a key neither a screen's nor an operation's, such as `user.ban`.
176
+ ...(report.permissions === undefined ? {} : { permissions: report.permissions }),
177
+ } satisfies AccessReport,
178
+ // The nav is cut from the whole report, before the report is cut to the screen's kayan.
179
+ screen: { ...full, kayan, nav: navOf(full, report) } as ScreenClient,
180
+ }
181
+ }
182
+
183
+ /** The layout route's answer: who, what they may do, and the screen. */
184
+ export const loadScreen = createServerFn({ method: 'GET', strict: { output: false } })
185
+ // Checked against this build's screens in `resolveScreen`.
186
+ .validator((screen: unknown) => screen)
187
+ .handler(async ({ data }) => {
188
+ const found = await resolveScreen(data)
189
+ return found === 'unauthorized' ? found : found ? { name: found.name, user: found.user, access: found.access, screen: found.screen } : null
190
+ })
191
+
192
+ /**
193
+ * What `/unauthorized` offers a signed-in actor: every screen the report lists for it, `null` without a session.
194
+ * `from` is only matched against this build's screens and pages, so nothing a caller writes is said back.
195
+ */
196
+ export const loadEnterable = createServerFn({ method: 'GET' })
197
+ .validator((asked?: { from?: unknown; locale?: unknown }) => ({
198
+ from: typeof asked?.from === 'string' ? asked.from : undefined,
199
+ locale: localeOf(typeof asked?.locale === 'string' ? asked.locale : undefined),
200
+ }))
201
+ .handler(async ({ data }): Promise<{
202
+ screens: { name: string; path: string }[]
203
+ refused?: { page: string; screen: { name: string; path: string } }
204
+ email?: string
205
+ organizations?: { id: string; name: string; slug?: string }[]
206
+ others?: { id: string; name: string; slug?: string }[]
207
+ impersonating?: string
208
+ } | null> => {
209
+ const [{ getRequest }, asas, { onPlatform, PLATFORM_SCREEN }] = await Promise.all([import('@tanstack/react-start/server'), getAsas(), import('asas')])
210
+ const request = getRequest()
211
+ const actor = (await asas.auth.actor(request.headers)) as Actor | null
212
+ if (!actor?.email) return null
213
+ const report = (await (await asas.handler(new Request(new URL('/api/access', request.url), { headers: request.headers }))).json()) as AccessReport
214
+ const screens = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
215
+ const entered = (report.screens ?? [])
216
+ .filter((name) => Object.hasOwn(screens, name) && (name === PLATFORM_SCREEN) === onPlatform())
217
+ .map((name) => ({ name, path: screens[name]!.client.path }))
218
+ const impersonating = actor.impersonatedBy ? { impersonating: actor.name ?? actor.email } : {}
219
+ if (entered.length) {
220
+ const { from, locale } = data
221
+ const home = from === undefined ? undefined : entered.find((one) => from === one.path || from.startsWith(`${one.path}/`))
222
+ const match = home && matchPage(screens[home.name]!.client, from!.slice(home.path.length))
223
+ if (!home || !match) return { screens: entered, ...impersonating }
224
+ const page = nameOfPage({ screen: screens[home.name]!.client, name: home.name, match, copy: { t: translator(locale), locale } })
225
+ return { screens: entered, refused: { page, screen: home }, ...impersonating }
226
+ }
227
+ const memberships = actor.organizations ?? []
228
+ const active = memberships.find((one) => one.id === actor.activeOrganizationId)
229
+ return {
230
+ screens: entered,
231
+ email: actor.email,
232
+ organizations: (active ? [active] : memberships).map(({ id, name, slug }) => ({ id, name, slug })),
233
+ others: onPlatform() ? [] : memberships.filter((one) => one !== active).map(({ id, name, slug }) => ({ id, name, slug })),
234
+ ...impersonating,
235
+ }
236
+ })
237
+
238
+ export { askedFor }
239
+
240
+ const holdsForm = (page: TreePage) => formIn(nodesOf(page)) !== undefined
241
+
242
+ const relatedOf = (screen: ScreenClient, kayan: ClientKayan) => Object.values(screen.kayan).filter((one) => kayan.fields.some((field) => field.relationTo === one.name))
243
+
244
+ const scopeTo = (node: { where?: string }, id: unknown) => ({ [node.where!]: { equals: id } })
245
+
246
+ /** One page's answer: which page the path asked for, the row a form draws, the rows a list draws, and what this actor may write. */
247
+ export const loadPage = createServerFn({ method: 'GET', strict: { output: false } })
248
+ .validator((asked: { screen: unknown; path: string; query: Record<string, unknown> }) => ({
249
+ screen: asked.screen,
250
+ path: String(asked.path),
251
+ query: askedFor(asked.query),
252
+ }))
253
+ .handler(async ({ data }) => {
254
+ const found = await resolveScreen(data.screen)
255
+ if (!found) return 'signed out' as const
256
+ if (found === 'unauthorized') return found
257
+ const { asas, screen } = found
258
+ const match = matchPage(screen, data.path)
259
+ // A page of a kayan the actor may not read is refused like a page whose key
260
+ // it does not hold, so one role meets one denial page.
261
+ if (!match) return matchPage(found.full, data.path) ? ('unauthorized' as const) : null
262
+ // A page of the screen's own reads nothing, but one naming a key is served
263
+ // only to an actor the report says holds it; its nav entry is only chrome.
264
+ if (match.kayan === undefined) {
265
+ const permission = screen.pages[match.page]?.permission
266
+ if (permission !== undefined && !holds(found.report, permission)) return 'unauthorized' as const
267
+ return { name: found.name, user: found.user, access: found.access, screen, kayan: undefined, page: match.page, value: null, first: undefined, related: {}, refused: [], creates: false, operations: {}, writable: {}, drafts: undefined }
268
+ }
269
+ const kayan = screen.kayan[match.kayan]
270
+ if (!kayan) return null
271
+ const page = kayan.pages[match.page]!
272
+ const tree = 'page' in page ? page : undefined
273
+ const actor = (await asas.auth.actor(found.request.headers)) ?? undefined
274
+ const operations = (await inProcess(asas)) as unknown as Record<string, { operations: Record<string, (input: object, options: { actor?: unknown; draft?: boolean }) => Promise<unknown>> } | undefined>
275
+ const globals = asas.global as unknown as typeof operations
276
+ const readRow = async (id: string, draft: boolean) =>
277
+ (await operations[kayan.name]!.operations.findByID!({ id }, { actor, ...(draft && { draft }) }).catch((thrown: unknown) => {
278
+ if (!caused(thrown)) throw thrown
279
+ return null
280
+ })) as Row | null
281
+ const report = found.access.collections?.[kayan.name]
282
+ // A create page with no field to write is refused like a page whose key is
283
+ // not held, not drawn as an empty form.
284
+ if (match.page === 'create' && !createsIn({ kayan, report })) return 'unauthorized' as const
285
+ // A kayan that keeps drafts, for an actor holding `findVersions` (the key a
286
+ // draft read and a draft save ask): the actor edits the latest draft.
287
+ // Without it, or where it leaves this row out, the actor reads the row and saves there.
288
+ const drafting = await rowReadsDrafts({
289
+ kayan,
290
+ operations: report?.operations,
291
+ id: kayan.single ? undefined : match.params.id,
292
+ findVersions: (input) => operations[kayan.name]!.operations.findVersions!(input, { actor }).catch((thrown: unknown) => {
293
+ if (!caused(thrown)) throw thrown
294
+ return null
295
+ }) as Promise<{ docs: readonly unknown[] } | null>,
296
+ })
297
+ let value: Row | null = null
298
+ if (kayan.single ? !tree || holdsForm(tree) : match.params.id !== undefined) {
299
+ if (kayan.single) value = (await globals[kayan.name]!.operations.findOne!({}, { actor })) as Row | null
300
+ else if (match.params.id) {
301
+ value = await readRow(match.params.id, drafting)
302
+ if (!value) return null
303
+ }
304
+ }
305
+ // A create page reads Draft from the start, since no published copy exists.
306
+ // A plain read finds only a published row, which tells draft from changed.
307
+ let drafts: FormProps['drafts']
308
+ if (drafting && tree && holdsForm(tree)) {
309
+ const published = value ? await readRow(String(value.id), false) : null
310
+ drafts = {
311
+ status: !published ? 'draft' : value!._status === 'draft' ? 'changed' : 'published',
312
+ ...(kayan.drafts!.autosave && { autosave: kayan.drafts!.autosave.interval }),
313
+ }
314
+ }
315
+ // The list this page draws, or the one it opens over. A URL the list does not
316
+ // offer is refused before `find` is asked anything, and the page draws the refusal.
317
+ const list = kayan.pages.list && 'page' in kayan.pages.list ? kayan.pages.list : undefined
318
+ const listPage = tree && listIn(nodesOf(tree)) ? tree : tree?.mode ? list : undefined
319
+ let first: { input: Record<string, unknown>; rows: Page } | undefined
320
+ const node = listPage && listIn(nodesOf(listPage))
321
+ if (node) {
322
+ try {
323
+ const input = findOf({
324
+ kayan,
325
+ query: readQuery(data.query, rulesOf({ kayan, node, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, kayan), locales } })),
326
+ node,
327
+ locales,
328
+ client: relatedOf(screen, kayan),
329
+ })
330
+ first = { input, rows: (await operations[kayan.name]!.operations.find!(input, { actor, ...(drafting && { draft: true }) })) as Page }
331
+ } catch (thrown) {
332
+ if (!(thrown instanceof QueryRefused)) throw thrown
333
+ }
334
+ }
335
+ // Each list of another kayan the page draws over its row. A kayan the screen
336
+ // does not cross (one the actor may not read) is read nothing, and so is a URL the list does not offer.
337
+ const related: Record<string, { input: Record<string, unknown>; rows: Page }> = {}
338
+ // Lists whose read the server refused, which the page says it cannot show rather than drawing nothing.
339
+ const refused: string[] = []
340
+ for (const one of tree && value ? relatedIn(nodesOf(tree), scopeOf({ values: {}, record: value, access: found.access, kayan })) : []) {
341
+ const target = screen.kayan[one.of!]
342
+ if (!target) continue
343
+ try {
344
+ const input = scopedTo(findOf({
345
+ kayan: target,
346
+ query: readQuery(listRaw(data.query, prefixOf(one)), rulesOf({ kayan: target, node: one, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, target), locales } })),
347
+ node: one,
348
+ locales,
349
+ client: relatedOf(screen, target),
350
+ }), scopeTo(one, value!.id))
351
+ const draft = readsDrafts(target, found.access.collections?.[target.name]?.operations)
352
+ related[prefixOf(one)] = { input, rows: (await operations[target.name]!.operations.find!(input, { actor, ...(draft && { draft }) })) as Page }
353
+ } catch (thrown) {
354
+ if (!(thrown instanceof QueryRefused) && !caused(thrown)) throw thrown
355
+ if ((thrown as { status?: unknown }).status === 403) refused.push(prefixOf(one))
356
+ }
357
+ }
358
+ const own = (name: string) => (asas.operations as readonly { kayan: string; name: string; input: Parameters<typeof takesKey>[0] }[]).find((one) => one.kayan === kayan.name && one.name === name)
359
+ const update = own('update')
360
+ // The row a relationship title points at, read as this actor; one the actor
361
+ // may not read names nothing. Only its id and title field go into the page's
362
+ // data, which is serialized into the document.
363
+ const titleField = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.kind === 'relationship')
364
+ const titledKayan = titleField ? screen.kayan[titleField.relationTo ?? ''] : undefined
365
+ const titledId = value && titledKayan ? idsOf(value[titleField!.name])[0] : undefined
366
+ const titledRow = titledId === undefined ? undefined : ((await operations[titledKayan!.name]?.operations.findByID?.({ id: titledId }, { actor }).catch((thrown: unknown) => {
367
+ if (!caused(thrown)) throw thrown
368
+ return null
369
+ })) as Row | null | undefined)
370
+ const titledField = titledKayan?.useAsTitle
371
+ const titledBy = titledRow && titledField ? ({ id: titledRow.id, [titledField]: titledRow[titledField] } as Row) : undefined
372
+ return {
373
+ name: found.name,
374
+ user: found.user,
375
+ access: found.access,
376
+ screen,
377
+ kayan: kayan.name,
378
+ page: match.page,
379
+ value,
380
+ first,
381
+ related,
382
+ refused,
383
+ creates: createsIn({ kayan, report }),
384
+ operations: report?.operations ?? {},
385
+ writable: report?.fields ?? {},
386
+ drafts,
387
+ ...(titledBy && { titledBy }),
388
+ // Whether the kayan's update takes the `expectedUpdatedAt` step's key, which the form then sends.
389
+ ...(update && takesKey(update.input, 'expectedUpdatedAt') && { expectedUpdatedAt: true }),
390
+ // And which of delete and restore do, for the Discards and the Versions tab's restore.
391
+ expectedUpdatedAtOn: ['delete', 'restoreVersion'].filter((name) => { const one = own(name); return !!one && takesKey(one.input, 'expectedUpdatedAt') }),
392
+ }
393
+ })
394
+
395
+ type ScreenData = Exclude<Awaited<ReturnType<typeof loadScreen>>, 'unauthorized' | null>
396
+ type PageData = Exclude<Awaited<ReturnType<typeof loadPage>>, 'signed out' | 'unauthorized' | null>
397
+
398
+ const pathOf = (href: string) => href.split(/[?#]/)[0]!
399
+
400
+ /**
401
+ * A screen's frame route's loader: resolves the session, access report and screen, and preloads the providers' files.
402
+ * It is not `beforeLoad` because route context must be serializable and the projection's `rules` are `unknown`.
403
+ */
404
+ export const frameLoader = async ({ screen, files, href }: { screen: string; files: Files; href: string }) => {
405
+ const found = await loadScreen({ data: screen })
406
+ if (!found) throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
407
+ if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
408
+ await preload(files, found.screen.providers)
409
+ return found
410
+ }
411
+
412
+ // Server: in process as this request's actor, so the first HTML has what a loader reads. Browser: the page's `fetch`.
413
+ // Only the browser's client is held to the served read's cap; in process a `limit` of 0 is allowed.
414
+ const toolsOf = createIsomorphicFn()
415
+ .server(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => {
416
+ const [{ getRequest }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
417
+ const request = getRequest()
418
+ const actor = (await asas.auth.actor(request.headers)) ?? undefined
419
+ const kayan = (await inProcess(asas)) as unknown as Parameters<typeof actingAs>[0]
420
+ return {
421
+ client: actingAs(kayan, actor),
422
+ request: (path, init) => asas.handler(new Request(new URL(path, request.url), { ...init, headers: mergedHeaders(request.headers, init?.headers) })),
423
+ access,
424
+ maxLimit,
425
+ }
426
+ })
427
+ .client(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => ({ client: clientOf(arm), request: fetch, access, maxLimit }))
428
+
429
+ /**
430
+ * A screen page route's loader: the page `path` names and only the files it draws. A page naming a `permission`
431
+ * is served only to an actor holding it; a page whose file exports a `loader` gets its return as `loaderData`.
432
+ */
433
+ export const pageLoader = async ({
434
+ screen,
435
+ files,
436
+ path,
437
+ query,
438
+ href,
439
+ }: {
440
+ screen: string
441
+ files: Files
442
+ path: string
443
+ query: Record<string, unknown>
444
+ href: string
445
+ }) => {
446
+ const found = await loadPage({ data: { screen, path, query } })
447
+ if (found === 'signed out') throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
448
+ if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
449
+ if (!found) throw notFound()
450
+ // A list asked for a page past its last is sent to its last, replacing the
451
+ // entry the URL made, so back does not land on it again.
452
+ const clamped = found.first && clampedHref({ href, page: found.first.rows.page, totalPages: found.first.rows.totalPages })
453
+ if (clamped) throw redirect({ href: clamped, replace: true })
454
+ await preload(files, ...partsOf(found))
455
+ if (found.kayan === undefined) {
456
+ const component = found.screen.pages[found.page]?.component
457
+ const module = component ? ((await files[component.slice(0, component.indexOf('#'))]?.()) as { loader?: unknown } | undefined) : undefined
458
+ if (typeof module?.loader === 'function') {
459
+ return { ...found, loaderData: (await (module.loader as (tools: LoaderTools) => Promise<unknown>)(await toolsOf({ access: found.access, maxLimit: found.screen.maxLimit }))) as unknown }
460
+ }
461
+ }
462
+ return found
463
+ }
464
+
465
+ const nameOfPage = ({ screen, name, match, row, titledBy, copy }: {
466
+ screen: ScreenClient
467
+ name: string
468
+ match: { kayan?: string; page: string }
469
+ row?: Row | null
470
+ titledBy?: Row | null
471
+ copy: { t: (key: string, fallback?: string) => string | undefined; locale: Locale }
472
+ }): string => {
473
+ if (match.kayan === undefined) return screen.nav.find((entry) => !entry.kayan && entry.page === match.page)?.label?.[copy.locale] ?? copy.t(`screens.${name}`, name) ?? name
474
+ const kayan = screen.kayan[match.kayan]!
475
+ const page = kayan.pages[match.page]
476
+ const many = !!page && 'page' in page && listIn(nodesOf(page)) !== undefined
477
+ if (match.page === 'create') return newTitleOf({ kayan, copy })
478
+ const field = kayan.fields.find((one) => one.name === kayan.useAsTitle)
479
+ const target = field?.kind === 'relationship' ? screen.kayan[field.relationTo ?? ''] : undefined
480
+ const titled = many ? undefined : target ? titledBy && rowTitle({ kayan: target, row: titledBy, locale: copy.locale }) : row && rowTitle({ kayan, row, locale: copy.locale })
481
+ return titled || titleOf({ kayan, copy, many })
482
+ }
483
+
484
+ /** A page's route `head`: the tab names the page, then the app. A route that drew its not-found has no data and says the page was not found. */
485
+ export const pageHead = (data: PageData | undefined, context: { locale: Locale; app: string }) =>
486
+ documentHead(context, (t) =>
487
+ data ? nameOfPage({ screen: data.screen, name: data.name, match: data, row: data.value, titledBy: 'titledBy' in data ? data.titledBy : undefined, copy: { t, locale: context.locale } }) : t('admin.notFoundTitle', 'Page not found'))
488
+
489
+ const partsOf = (found: PageData): unknown[] => {
490
+ if (found.kayan === undefined) return [found.screen.pages[found.page]]
491
+ const kayan = found.screen.kayan[found.kayan]!
492
+ const page = kayan.pages[found.page]!
493
+ return [page, 'page' in page && page.mode ? kayan.pages.list : undefined, Object.values(found.screen.kayan).map((one) => one.fields)]
494
+ }
495
+
496
+ const useScreenState = (data: ScreenData): ScreenState => {
497
+ const { dir, locale, t } = useCopy()
498
+ return { t, locale, dir, screen: data.screen, access: data.access, user: data.user, client: clientOf(arm) }
499
+ }
500
+
501
+ /**
502
+ * The bar that says the session is an impersonation, with the way out. Stopping
503
+ * is a sign-out: the staff member's own session lives in the platform app under
504
+ * that app's cookie, and the sign-out answers where they go next.
505
+ */
506
+ export function ImpersonationBar({ name }: { name: string }): ReactElement {
507
+ const { t } = useCopy()
508
+ return (
509
+ <div role="alert" data-impersonating="" className="flex flex-wrap items-center justify-between gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-black">
510
+ <span>
511
+ {t('admin.impersonating', 'You are impersonating')} <strong>{name}</strong>.
512
+ </span>
513
+ <button
514
+ type="button"
515
+ className="rounded-md border border-black/40 px-3 py-1 font-semibold"
516
+ // `replace`, so Back does not return to the page signed out of.
517
+ onClick={() => void authArm('sign-out').then((answered) => window.location.replace(String(answered.body?.url ?? '/auth/sign-in')))}
518
+ >
519
+ {t('admin.stopImpersonating', 'Stop impersonating')}
520
+ </button>
521
+ </div>
522
+ )
523
+ }
524
+
525
+ function ScreenBar({ user }: { user: ScreenData['user'] }): ReactElement | null {
526
+ const { t } = useCopy()
527
+ const [failed, setFailed] = useState(false)
528
+ useEffect(() => {
529
+ const show = (event: Event) => setFailed((event as CustomEvent<boolean>).detail)
530
+ window.addEventListener('mithaq:after-commit', show)
531
+ return () => window.removeEventListener('mithaq:after-commit', show)
532
+ }, [])
533
+ return (
534
+ <>
535
+ {'impersonating' in user && user.impersonating ? <ImpersonationBar name={user.name} /> : null}
536
+ {failed ? (
537
+ <p role="status" className="rounded-md border border-amber-500/50 px-3 py-2 text-sm">
538
+ {t('admin.afterCommitFailed', 'Saved, but a follow-up action failed, so something this save should have caused, such as an email or a notification, may be missing. Please contact an administrator.')}
539
+ </p>
540
+ ) : null}
541
+ </>
542
+ )
543
+ }
544
+
545
+ /** The screen's own frame: its providers, outermost first, around the frame's bar and the page. */
546
+ export function ScreenFrame({ files, data, children }: { files: Files; data: ScreenData; children: ReactNode }): ReactElement {
547
+ const refs = refsOf(files, data.screen)
548
+ // One per frame: made per request on the server, so no row one actor read
549
+ // reaches another's render; held for the frame's life in the browser.
550
+ const [queries] = useState(() => new QueryClient())
551
+ // A hydrating page has had no loader run in the browser, so every file the
552
+ // frame and page draw is asked for at once, off the answer the page's loader
553
+ // sent, not one round trip per file as React reaches it.
554
+ const page = useMatches({ select: (matches) => matches.at(-1)?.loaderData as PageData | undefined })
555
+ useState(() => typeof window !== 'undefined' && void preload(files, data.screen.providers, ...(page?.screen ? partsOf(page) : [])))
556
+ // A link inside the screen is followed by the router, so a tap reads the
557
+ // next page's data instead of loading a whole document.
558
+ const router = useRouter()
559
+ const base = data.screen.path
560
+ useEffect(() => {
561
+ const follow = (event: MouseEvent) => {
562
+ const href = routedHref({ event, origin: window.location.origin, base })
563
+ if (href === undefined) return
564
+ event.preventDefault()
565
+ void router.navigate({ href })
566
+ }
567
+ document.addEventListener('click', follow)
568
+ return () => document.removeEventListener('click', follow)
569
+ }, [router, base])
570
+ // Tab stays inside an open dialog, alert dialog or sheet, as in a native modal.
571
+ useEffect(() => holdFocus(document.body), [])
572
+ // A component that changed what the page reads, with no kayan operation to
573
+ // say so, asks for a re-read of the lists and the page's loader without a
574
+ // reload, so the scroll stays.
575
+ useEffect(() => {
576
+ const again = () => void Promise.all([queries.invalidateQueries(), router.invalidate()])
577
+ window.addEventListener('mithaq:refresh', again)
578
+ return () => window.removeEventListener('mithaq:refresh', again)
579
+ }, [queries, router])
580
+ // Suspends only while a hydrating page's files load.
581
+ return (
582
+ <QueryClientProvider client={queries}>
583
+ <ScreenContext value={useScreenState(data)}>
584
+ <Suspense fallback={null}>
585
+ {data.screen.providers.reduceRight<ReactElement>(
586
+ (inner, path) => {
587
+ const Provider = refOf({ screen: { refs }, place: 'provider', path })!
588
+ return <Provider>{inner}</Provider>
589
+ },
590
+ <>
591
+ <ScreenBar user={data.user} />
592
+ {children}
593
+ </>,
594
+ )}
595
+ </Suspense>
596
+ </ScreenContext>
597
+ </QueryClientProvider>
598
+ )
599
+ }
600
+
601
+ type PageViewProps = {
602
+ files: Files
603
+ data: PageData & { loaderData?: unknown }
604
+ query: RawQuery
605
+ onQuery: (search: Record<string, string>) => void
606
+ }
607
+
608
+ /** One page, inside what the host provides for it: the screen, and on a kayan's page that kayan and the row, which `useKayan()` and `useRecord()` read. */
609
+ export function ScreenPageView(props: PageViewProps): ReactElement {
610
+ const { data } = props
611
+ const state = useScreenState(data)
612
+ // So a shell marks its home link on the home page alone.
613
+ const own = data.screen.pages[data.page]?.path ?? '/'
614
+ return (
615
+ <ScreenContext value={data.kayan === undefined ? { ...state, path: own === '/' ? data.screen.path : `${data.screen.path}${own}` } : { ...state, kayan: data.screen.kayan[data.kayan], record: data.value }}>
616
+ <PageBody {...props} />
617
+ </ScreenContext>
618
+ )
619
+ }
620
+
621
+ /**
622
+ * A screen page route's not-found, drawn under the screen's frame in the shell
623
+ * its pages use, so the nav stays and the way back is the screen's home. The
624
+ * shell is the first page tree's, which is every page's in this template.
625
+ */
626
+ export function ScreenNotFound({ files, screen: name }: { files: Files; screen: string }): ReactElement {
627
+ const { t } = useCopy()
628
+ const screen = useScreen()
629
+ const refs = refsOf(files, screen)
630
+ // A page that opens over its list is drawn inside the list's shell, so it names none to draw here.
631
+ const framed = Object.values(screen.kayan).flatMap((kayan) => Object.values(kayan.pages).flatMap((page) => ('page' in page && !page.mode ? [{ kayan, page }] : [])))[0]
632
+ const back = (t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => t(`screens.${name}`, name) ?? name)
633
+ const regions = {
634
+ header: <h1 className="text-2xl font-bold tracking-tight">{t('admin.notFoundTitle', 'Page not found')}</h1>,
635
+ nav: <nav aria-label={t('admin.navigation', 'Navigation')}><NavLinks /></nav>,
636
+ main: (
637
+ <div className="flex flex-col items-start gap-4">
638
+ <p className="text-muted-foreground text-sm">{t('admin.notFound', 'That page does not exist.')}</p>
639
+ <a className="text-foreground text-sm underline underline-offset-4" href={screen.path}>{back}</a>
640
+ </div>
641
+ ),
642
+ }
643
+ if (!framed) return <main className="p-6">{regions.header}{regions.main}</main>
644
+ return <>{frameOf({ screen: { refs, ...framed }, t, path: framed.page.page.shell, regions })}</>
645
+ }
646
+
647
+ function PageBody({ files, data, query, onQuery }: PageViewProps): ReactElement | null {
648
+ const refs = refsOf(files, data.screen)
649
+ const router = useRouter()
650
+ const queries = useQueryClient()
651
+ const { locale, t } = useCopy()
652
+ if (data.kayan === undefined) {
653
+ const Page = refOf({ screen: { refs }, place: 'page', path: data.screen.pages[data.page]?.component })
654
+ return Page ? <Page loaderData={data.loaderData} /> : null
655
+ }
656
+ const kayan = data.screen.kayan[data.kayan]!
657
+ const declared = kayan.pages[data.page]!
658
+ if ('component' in declared) {
659
+ const Whole = refOf({ screen: { refs }, place: 'component', path: declared.component })
660
+ return Whole ? <Whole page={declared} /> : null
661
+ }
662
+ const page = declared
663
+ const base = data.screen.path
664
+ const hrefOf = (target: ClientKayan, pageName: string | undefined, id?: string) => {
665
+ const path = pageName === undefined ? undefined : target.pages[pageName]?.path
666
+ return path ? `${base}${id ? path.replace(':id', encodeURIComponent(id)) : path}` : undefined
667
+ }
668
+ // A row opens its kayan's edit page, else the one page whose path draws a
669
+ // row (how the platform opens a record it only reads).
670
+ const rowPage = (target: ClientKayan) => (target.pages.edit ? 'edit' : Object.keys(target.pages).find((one) => target.pages[one]!.path.includes(':id')))
671
+ const related = relatedOf(data.screen, kayan)
672
+ const go = (href: string | undefined) => void (href ? router.navigate({ href }) : router.invalidate())
673
+ const written = <Written,>(write: Promise<Written>, name = kayan.name) =>
674
+ write.then((out) => {
675
+ void queries.invalidateQueries({ queryKey: rowsKey(name) })
676
+ return out
677
+ })
678
+
679
+ // A list of another kayan on this page: the rows whose `where` holds the row
680
+ // the page draws. The screen crosses only the kayan the actor may read, so one
681
+ // it may not is refused here, as is one whose read the server refused.
682
+ const relatedList = (target: TreePage, node: Parameters<NonNullable<Draw['list']>>[0]['node'], index: number, own?: ReactElement) => {
683
+ const listed = data.screen.kayan[node.of!]
684
+ // Both look the same to the viewer: rows it cannot see.
685
+ if (!listed || data.refused.includes(prefixOf(node))) {
686
+ return (
687
+ <p key={index} role="alert" data-mithaq-refused-list={node.of} className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
688
+ {t('admin.cannotSee', 'You can\'t see this')}
689
+ </p>
690
+ )
691
+ }
692
+ const id = data.value?.id
693
+ if (id === undefined) return null
694
+ const prefix = prefixOf(node)
695
+ const report = data.access.collections?.[listed.name]
696
+ const targets = relatedOf(data.screen, listed)
697
+ return (
698
+ <ScreenList
699
+ key={index}
700
+ props={{
701
+ kayan: listed,
702
+ node,
703
+ client: targets,
704
+ api: readsOf([listed, ...targets]),
705
+ creates: createsIn({ kayan: listed, report }),
706
+ operations: report?.operations ?? {},
707
+ writes: (field) => report?.fields?.[field]?.update === true,
708
+ access: data.access,
709
+ t,
710
+ locale,
711
+ locales,
712
+ href: (row) => hrefOf(listed, rowPage(listed), row) ?? '#',
713
+ newHref: '#',
714
+ screen: { refs, kayan: listed, page: target },
715
+ edit: {
716
+ client: {
717
+ create: (input) => written(arm[listed.name]!.create!(input), listed.name),
718
+ update: (input) => written(arm[listed.name]!.update!(input), listed.name),
719
+ delete: (input) => written(arm[listed.name]!.delete!(input), listed.name),
720
+ },
721
+ writes: (field, on) => report?.fields?.[field]?.[on] === true,
722
+ prefill: { [node.where!]: id },
723
+ },
724
+ }}
725
+ raw={listRaw(query, prefix)}
726
+ onQuery={(search) => onQuery(withList({ raw: query, prefix, written: search }))}
727
+ first={data.related[prefix]}
728
+ scope={scopeTo(node, id)}
729
+ find={(input, options) => arm[listed.name]!.find!(input, options) as Promise<Page>}
730
+ run={(operation, input) => arm[listed.name]![operation]!(input)}
731
+ own={own}
732
+ />
733
+ )
734
+ }
735
+
736
+ const draw = (target: TreePage): Draw => {
737
+ return {
738
+ kayan,
739
+ copy: { t, locale },
740
+ issues: {},
741
+ // A page drawn here holds no form, so a `when` reads the row and the report alone.
742
+ scope: scopeOf({ values: {}, record: data.value, access: data.access, kayan }),
743
+ // A leaf is the row's value drawn by its cell, read and never edited.
744
+ control: (leaf) => {
745
+ const field = kayan.fields.find((one) => one.name === (typeof leaf === 'string' ? leaf : leaf.field))
746
+ return field ? (
747
+ <FieldValue
748
+ key={field.name}
749
+ kayan={kayan}
750
+ field={field}
751
+ leaf={typeof leaf === 'string' ? undefined : leaf}
752
+ row={data.value}
753
+ copy={{ t, locale }}
754
+ api={readsOf(related)}
755
+ kayans={related}
756
+ screen={{ refs, kayan, page: target }}
757
+ />
758
+ ) : null
759
+ },
760
+ component: (path, index) => {
761
+ const Component = refOf({ screen: { refs }, place: 'component', path })
762
+ return Component ? <Component key={index} page={target} /> : null
763
+ },
764
+ layout: ({ path, regions, index }) => frameOf({ screen: { refs, kayan, page: target }, t, path, regions, key: index }),
765
+ // A list node naming a component is drawn by it, which reads through
766
+ // `useList()` what the registry's list is handed.
767
+ list: ({ node, index, component }) => {
768
+ const Own = component ? refOf({ screen: { refs }, place: 'component', path: component }) : undefined
769
+ if (node.of) return relatedList(target, node, index, Own ? <Own page={target} /> : undefined)
770
+ return (
771
+ <ScreenList
772
+ key={index}
773
+ props={{
774
+ kayan,
775
+ client: related,
776
+ api: readsOf([kayan, ...related]),
777
+ creates: data.creates && !!kayan.pages.create,
778
+ operations: data.operations,
779
+ writes: (field) => data.writable[field]?.update === true,
780
+ access: data.access,
781
+ t,
782
+ locale,
783
+ locales,
784
+ href: (id) => hrefOf(kayan, rowPage(kayan), id) ?? '#',
785
+ newHref: hrefOf(kayan, 'create') ?? '#',
786
+ screen: { refs, kayan, page: target },
787
+ }}
788
+ raw={query}
789
+ onQuery={onQuery}
790
+ first={data.first}
791
+ find={(input, options) => arm[kayan.name]!.find!(input, options) as Promise<Page>}
792
+ run={(operation, input) => arm[kayan.name]![operation]!(input)}
793
+ own={Own ? <Own page={target} /> : undefined}
794
+ />
795
+ )
796
+ },
797
+ nav: () => <NavLinks />,
798
+ button: (node, index) => {
799
+ const button = pageButtonOf({ kayan, node, allowed: data.operations })
800
+ if (!button) return null
801
+ const call = async (input: Row) => {
802
+ await written(arm[kayan.name]![button.name]!({ ...input, ...(data.value?.id !== undefined && { id: data.value.id }) }))
803
+ await router.invalidate()
804
+ }
805
+ // A project's button component is handed a run that asks the confirm
806
+ // first; the registry's own button asks it before calling.
807
+ const Own = node.component ? refOf({ screen: { refs }, place: 'button', path: node.component }) : undefined
808
+ return Own ? (
809
+ <OwnButton key={index} Own={Own} operation={button} busy={false} copy={{ t, locale }} call={call} />
810
+ ) : (
811
+ <NodeButton key={index} node={node} copy={{ t, locale }} busy={false} run={() => void call({})} />
812
+ )
813
+ },
814
+ }
815
+ }
816
+ const drawn = (target: TreePage) => pageOf({ refs, kayan, page: target }, draw(target))
817
+ if (readsAsValues({ page, value: data.value, operations: data.operations })) return <>{drawn(page)}</>
818
+
819
+ const listHref = hrefOf(kayan, 'list')
820
+ const client: FormClient = kayan.single
821
+ ? {
822
+ create: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
823
+ update: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
824
+ delete: () => Promise.reject(new Error(`"${kayan.name}" is one row and declares no delete.`)),
825
+ }
826
+ : {
827
+ create: (input, options) => written(arm[kayan.name]!.create!(input, options)),
828
+ update: (input, options) => written(arm[kayan.name]!.update!(input, options)),
829
+ delete: (input) => written(arm[kayan.name]!.delete!(input)),
830
+ run: (operation, input) => written(arm[kayan.name]![operation]!(input)),
831
+ findByID: (input) => arm[kayan.name]!.findByID!(input),
832
+ }
833
+ const form = (
834
+ <ScreenForm
835
+ kayan={kayan}
836
+ screen={{ refs, kayan, page }}
837
+ client={client}
838
+ value={data.value}
839
+ t={t}
840
+ locale={locale}
841
+ locales={locales}
842
+ writes={(field, on) => data.writable[field]?.[on] === true}
843
+ kayans={related}
844
+ api={readsOf(related)}
845
+ operations={data.operations}
846
+ related={draw(page).list}
847
+ edit={(id) => hrefOf(kayan, 'edit', id)}
848
+ list={listHref}
849
+ drafts={data.drafts}
850
+ expectedUpdatedAt={data.expectedUpdatedAt}
851
+ expectedUpdatedAtOn={data.expectedUpdatedAtOn}
852
+ />
853
+ )
854
+ const list = kayan.pages.list
855
+ if (page.mode && list && 'page' in list) {
856
+ return (
857
+ <>
858
+ {drawn(list)}
859
+ {/* Named for a screen reader by the page's title, a create as new. */}
860
+ <RecordFrame mode={page.mode} title={(data.value ? titleOf : newTitleOf)({ kayan, copy: { t, locale } })} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={() => go(listHref)}>
861
+ {form}
862
+ </RecordFrame>
863
+ </>
864
+ )
865
+ }
866
+ return form
867
+ }
868
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 d96a3dd603b5aead8e3d82e8792fa40d5f4dac1bfbee2e7064ab2be888b595a6