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,686 +0,0 @@
1
- import { use, useEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from 'react'
2
- import { ChevronLeftIcon, ChevronRightIcon, FileIcon, ImageIcon, ImageOffIcon, UploadIcon, XIcon } from 'lucide-react'
3
- import { $create, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
4
- import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
5
- import { $insertNodeToNearestRoot } from '@lexical/utils'
6
- import { allowedType, type ClientField, type ClientKayan, type FetchClient } from 'mithaq/admin'
7
- import { ScreenContext } from 'mithaq/admin/react'
8
- import { UploadNode } from 'mithaq/rich-text'
9
- import { Button, buttonVariants } from '@/components/ui/button'
10
- import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
11
- import { FieldShell, describedBy } from './field-shell.tsx'
12
- import { changeOf, controlFor, createsIn, isForbidden, labelFor, listed, localesOf, noValue, readOnlyField, refusalKeyOf, type ControlProps, type FindApi, type FoundPage, type Translate } from './controls.tsx'
13
- import type { CellProps } from './cells.tsx'
14
- import { RelationshipControl, findByIds, idsOf } from './relationship-control.tsx'
15
- import { rowKey, type RelationshipTitles } from './relationship-cell.tsx'
16
- import type { RichTextPick } from './rich-text.tsx'
17
-
18
- type Row = Record<string, unknown>
19
-
20
- type UploadRequest = { method: 'PUT' | 'POST'; url: string; headers?: Record<string, string> }
21
- type UploadInstructions = { request: UploadRequest; file: { uploadReference: unknown } }
22
-
23
- /** The copy key of the refusal the server would answer for a file of this type and size, or nothing. The server checks both again, and reads the bytes besides. */
24
- export const refusedFile = (file: { type: string; size: number }, storage?: { mimeTypes: readonly string[]; fileSize: number }): string | undefined => {
25
- if (!storage) return undefined
26
- if (!allowedType(file.type, storage.mimeTypes)) return 'error.invalid.upload_type'
27
- if (file.size > storage.fileSize) return 'error.invalid.upload_size'
28
- return file.size < 1 ? 'error.invalid.upload_request' : undefined
29
- }
30
-
31
- /** The refusals about the file itself, which sending it again meets again, so no retry is offered. A failed send, a 412 or a 5xx may pass a second time. */
32
- const final = new Set(['error.invalid.upload_type', 'error.invalid.upload_size', 'error.invalid.upload_verify'])
33
-
34
- const vendorNames: Readonly<Record<string, string>> = {
35
- 'vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
36
- 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
37
- 'vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
38
- 'vnd.ms-excel': 'XLS',
39
- 'msword': 'DOC',
40
- }
41
-
42
- /**
43
- * A file type as a person names it, the subtype in capitals (`image/png` is PNG), since the raw
44
- * type reads as a path and breaks at its slash in an Arabic sentence. A vendor type is named by
45
- * its extension where well known, else by the last part of its name. A wildcard such as `image/*` is kept whole.
46
- */
47
- export const typeName = (mimeType: string): string => {
48
- const [type = '', subtype = ''] = mimeType.split('/')
49
- if (!subtype || subtype === '*') return type ? `${type}/*` : mimeType
50
- const bare = subtype.split('+')[0]!
51
- return Object.hasOwn(vendorNames, bare) ? vendorNames[bare]! : bare.replace(/^(x-|vnd\.)/, '').split('.').at(-1)!.toUpperCase()
52
- }
53
-
54
- /** The types a kayan takes, each named once, in the order the kayan lists them. */
55
- export const typeNames = (mimeTypes: readonly string[]): string[] => [...new Set(mimeTypes.map(typeName))]
56
-
57
- /** A size in bytes as a person reads it: bytes, kilobytes or megabytes (1024 each), every unit written out because the platform's short forms mix words with abbreviations. */
58
- export const sizeOf = (bytes: number, locale: string): string => {
59
- const [value, unit] = bytes < 1024 ? [bytes, 'byte'] : bytes < 1024 * 1024 ? [bytes / 1024, 'kilobyte'] : [bytes / (1024 * 1024), 'megabyte']
60
- return new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay: 'long', maximumFractionDigits: 1 }).format(value)
61
- }
62
-
63
- const blank = (value: unknown) => value === undefined || value === null || value === ''
64
-
65
- /** The words of a refusal: the project's copy under the key the server sent, and the server's own sentence where the copy holds none. */
66
- const refusalOf = (thrown: unknown, t: Translate) => {
67
- const message = thrown instanceof Error ? thrown.message : String(thrown)
68
- const key = refusalKeyOf(thrown)
69
- return { words: (key && t(key, message)) || message, retry: !final.has(key ?? '') }
70
- }
71
-
72
- /** Headers a browser sets itself and refuses from a script, which the S3 adapter signs and the browser sends anyway. */
73
- const forbidden = new Set(['content-length', 'host'])
74
-
75
- /** The bytes sent where the instructions say, with progress as a fraction. Over `XMLHttpRequest` because `fetch` reports no upload progress. */
76
- export const put = ({ request, file, progress }: { request: UploadRequest; file: Blob; progress: (fraction: number) => void }) =>
77
- new Promise<void>((resolve, reject) => {
78
- const sending = new XMLHttpRequest()
79
- sending.open(request.method, request.url)
80
- for (const [name, value] of Object.entries(request.headers ?? {})) if (!forbidden.has(name.toLowerCase())) sending.setRequestHeader(name, value)
81
- sending.upload.onprogress = (event) => {
82
- if (event.lengthComputable) progress(event.loaded / event.total)
83
- }
84
- sending.onload = () => {
85
- if (sending.status >= 200 && sending.status < 300) return resolve()
86
- let message = `HTTP ${sending.status}`
87
- try {
88
- const said = (JSON.parse(sending.responseText) as { message?: unknown }).message
89
- if (typeof said === 'string') message = said
90
- } catch {
91
- // A store's own refusal is often XML; the status is what is left to say.
92
- }
93
- reject(Object.assign(new Error(message), { status: sending.status }))
94
- }
95
- sending.onerror = () => reject(new Error('The file could not be sent.'))
96
- sending.send(file)
97
- })
98
-
99
- /** The whole upload in the three steps the server asks for: the instructions, the bytes, then the create carrying the signed reference beside the row's own fields. Answers the row the create made. */
100
- export const uploadFile = async ({ client, kayan, file, data, progress, send = put }: {
101
- client: FetchClient
102
- kayan: string
103
- file: File
104
- data: Row
105
- progress: (fraction: number) => void
106
- send?: typeof put
107
- }): Promise<Row> => {
108
- const instructions = (await client({ kayan, operation: 'uploadInstructions', input: { filename: file.name, filesize: file.size, mimeType: file.type } })) as UploadInstructions
109
- await send({ request: instructions.request, file, progress })
110
- progress(1)
111
- return (await client({ kayan, operation: 'create', input: { ...data, uploadReference: instructions.file.uploadReference } })) as Row
112
- }
113
-
114
- /** A file row's words for a screen reader: its alternative text in the viewer's locale, else its name. */
115
- const altOf = (row: Row, locale: string) => {
116
- const alt = row.alt && typeof row.alt === 'object' ? (row.alt as Record<string, unknown>)[locale] : row.alt
117
- return typeof alt === 'string' && alt ? alt : String(row.filename ?? '')
118
- }
119
-
120
- /** The file's own address, computed by the server on every read and never stored. */
121
- const urlOf = (row: Row, key: 'url' | 'thumbnailURL') => (typeof row[key] === 'string' ? (row[key] as string) : undefined)
122
-
123
- // A small thumbnail takes `max-w-none` and `shrink-0`: the preflight's `max-width: 100%` makes an image count as no width when a table sizes its column.
124
- const sizes = { xs: 'size-8 shrink-0 max-w-none', sm: 'size-16 shrink-0 max-w-none', lg: 'max-h-64 w-auto max-w-full' } as const
125
- /** The pixels a thumbnail is drawn at, so the browser lays it out before the file arrives. */
126
- const pixels = { xs: 32, sm: 64 } as const
127
-
128
- /** What stands where an image cannot be drawn: the reader may not read it, or the file is gone. */
129
- function Missing({ t, size }: { t: Translate; size: keyof typeof sizes }): ReactElement {
130
- const words = t('admin.upload.missing', 'This file cannot be shown.') ?? 'This file cannot be shown.'
131
- return (
132
- <span data-upload-missing="" title={words} className={`bg-muted text-muted-foreground inline-flex items-center justify-center gap-2 rounded-md border ${size === 'lg' ? 'p-4 text-sm' : sizes[size]}`}>
133
- <ImageOffIcon className="size-4 shrink-0" aria-hidden />
134
- <span className={size === 'lg' ? undefined : 'sr-only'}>{words}</span>
135
- </span>
136
- )
137
- }
138
-
139
- /** One file drawn: its thumbnail where it is an image, else its name and type. The image goes through the file route off the row's computed `thumbnailURL`, so the row's own access decides who sees it. */
140
- export function FileThumb({ row, t, locale, size = 'sm' }: { row: Row; t: Translate; locale: string; size?: keyof typeof sizes }): ReactElement {
141
- const [failed, setFailed] = useState(false)
142
- const thumbnail = urlOf(row, 'thumbnailURL')
143
- if (thumbnail && !failed) {
144
- return <img src={thumbnail} alt={altOf(row, locale)} loading="lazy" decoding="async" {...(size !== 'lg' && { width: pixels[size], height: pixels[size] })} onError={() => setFailed(true)} data-upload-thumbnail="" className={`rounded-md border object-cover ${sizes[size]}`} />
145
- }
146
- if (thumbnail) return <Missing t={t} size={size} />
147
- return (
148
- <span data-upload-file="" className="inline-flex min-w-0 items-center gap-2">
149
- <FileIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
150
- <span className="min-w-0 truncate" dir="auto">{String(row.filename ?? row.id ?? '')}</span>
151
- {size === 'xs' ? null : <span className="text-muted-foreground shrink-0 text-xs" dir="ltr">{String(row.mimeType ?? '')}</span>}
152
- </span>
153
- )
154
- }
155
-
156
- /**
157
- * A file read on a record page: the image, opening the file itself, or a
158
- * link that downloads it, both through the access-gated file route.
159
- */
160
- export function FileValue({ row, t, locale }: { row: Row; t: Translate; locale: string }): ReactElement {
161
- const url = urlOf(row, 'url')
162
- if (urlOf(row, 'thumbnailURL')) {
163
- return (
164
- <a href={url} target="_blank" rel="noopener noreferrer" className="inline-block max-w-full">
165
- <FileThumb row={row} t={t} locale={locale} size="lg" />
166
- </a>
167
- )
168
- }
169
- return (
170
- <a href={url} download={typeof row.filename === 'string' ? row.filename : ''} data-upload-download="" className="inline-flex max-w-full underline underline-offset-2">
171
- <FileThumb row={row} t={t} locale={locale} />
172
- </a>
173
- )
174
- }
175
-
176
- /** One held id as the map read it: its row, loading until the read answers, a dash where the read failed, and a row the viewer cannot see where it was refused or left out. Never the raw id. */
177
- const heldFile = ({ titles, target, id, t, locale, size }: {
178
- titles: RelationshipTitles | undefined
179
- target: string
180
- id: string
181
- t: Translate
182
- locale: string
183
- size: keyof typeof sizes
184
- }) => {
185
- const found = titles?.get(rowKey(target, id))
186
- if (found === null || found === undefined) return <span key={id}>{t('admin.loading', 'Loading…') ?? 'Loading…'}</span>
187
- if (found instanceof Error) return <span key={id}>—</span>
188
- if (found && typeof found === 'object') return size === 'lg' ? <FileValue key={id} row={found} t={t} locale={locale} /> : <FileThumb key={id} row={found} t={t} locale={locale} size={size} />
189
- return <span key={id} className="text-muted-foreground text-xs">{t('admin.cannotSee', 'You can\'t see this') ?? 'You can\'t see this'}</span>
190
- }
191
-
192
- const totalToShow = 3
193
-
194
- /** The `upload` kind's cell: a small thumbnail for an image and the name for any other file; a set draws its first three and counts the rest. */
195
- export function UploadCell({ field, locale, t, titles, value }: CellProps): ReactNode {
196
- const ids = idsOf(value)
197
- if (!ids.length) return noValue
198
- const rest = ids.length - totalToShow
199
- return (
200
- <span className="inline-flex max-w-full items-center gap-1">
201
- {ids.slice(0, totalToShow).map((id) => heldFile({ titles, target: field.relationTo ?? '', id, t, locale, size: 'xs' }))}
202
- {rest > 0 ? <span className="text-muted-foreground text-xs">{(t('admin.andMore', 'and {count} more') ?? 'and {count} more').replace('{count}', () => rest.toLocaleString(locale))}</span> : null}
203
- </span>
204
- )
205
- }
206
-
207
- /** A record page's read of an upload: each file whole, the image or its download link. */
208
- export function UploadValue({ field, locale, t, titles, value }: CellProps): ReactNode {
209
- const ids = idsOf(value)
210
- return ids.length ? <span className="grid gap-2">{ids.map((id) => heldFile({ titles, target: field.relationTo ?? '', id, t, locale, size: 'lg' }))}</span> : null
211
- }
212
-
213
- /**
214
- * What the upload dialog asks beside the file: every field of the kayan a
215
- * create must be sent and the caller writes, its alternative text among
216
- * them. The file's own columns are the runner's and never asked.
217
- */
218
- export const askedFields = (target: ClientKayan): ClientField[] =>
219
- target.fields.filter((field) => field.rules.required === true && !readOnlyField(field, 'create') && field.kind !== 'join')
220
-
221
- /**
222
- * The dialog a new file is made in: the file, the fields the create asks,
223
- * the send with its progress, and the server's refusal with a retry. It
224
- * answers the row the create made.
225
- */
226
- export function UploadDialog({ open, onOpenChange, target, client, t, locale, locales, onDone }: {
227
- open: boolean
228
- onOpenChange: (open: boolean) => void
229
- target: ClientKayan
230
- client: FetchClient
231
- t: Translate
232
- locale: string
233
- locales: readonly string[]
234
- onDone: (row: Row) => void
235
- }): ReactElement {
236
- const [file, setFile] = useState<File | null>(null)
237
- const [values, setValues] = useState<Row>({})
238
- const [progress, setProgress] = useState<number | null>(null)
239
- const [refused, setRefused] = useState<{ words: string; retry: boolean }>()
240
- const [noFile, setNoFile] = useState(false)
241
- const [wrong, setWrong] = useState<{ words: Record<string, string>; locales: Readonly<Record<string, readonly string[]>> }>({ words: {}, locales: {} })
242
- const fields = askedFields(target)
243
- const busy = progress !== null
244
- const inputId = `upload-${target.name}-file`
245
- const close = () => {
246
- if (busy) return
247
- setFile(null)
248
- setValues({})
249
- setRefused(undefined)
250
- setNoFile(false)
251
- setWrong({ words: {}, locales: {} })
252
- onOpenChange(false)
253
- }
254
- const send = async () => {
255
- if (busy) return
256
- if (!file) return setNoFile(true)
257
- const early = refusedFile(file, target.storage)
258
- if (early) return setRefused({ words: t(early, early) ?? early, retry: false })
259
- const missing = { words: {} as Record<string, string>, locales: {} as Record<string, readonly string[]> }
260
- for (const field of fields) {
261
- const held = values[field.name]
262
- const empty = field.rules.localized === true ? locales.filter((one) => blank((held as Row | undefined)?.[one])) : blank(held) ? [''] : []
263
- if (!empty.length) continue
264
- const key = `${target.name}.${field.name}.required`
265
- missing.words[field.name] = t(key, key) ?? key
266
- if (field.rules.localized === true) missing.locales[field.name] = empty
267
- }
268
- setWrong(missing)
269
- setRefused(undefined)
270
- if (Object.keys(missing.words).length) return
271
- setProgress(0)
272
- try {
273
- const row = await uploadFile({ client, kayan: target.name, file, data: values, progress: setProgress })
274
- setProgress(null)
275
- setFile(null)
276
- setValues({})
277
- onDone(row)
278
- onOpenChange(false)
279
- } catch (thrown) {
280
- setProgress(null)
281
- // A schema refusal is drawn under each field it names, keyed by the path's first segment.
282
- const issues = (thrown as { data?: { params?: { issues?: unknown } } } | null)?.data?.params?.issues
283
- const words: Record<string, string> = {}
284
- for (const issue of Array.isArray(issues) ? (issues as { path?: unknown; message?: unknown }[]) : []) {
285
- const name = typeof issue?.path === 'string' ? issue.path.split('.')[0]! : ''
286
- if (fields.some((field) => field.name === name) && typeof issue.message === 'string') words[name] ??= t(issue.message, issue.message) ?? issue.message
287
- }
288
- if (Object.keys(words).length) return setWrong({ words, locales: localesOf(thrown) })
289
- setRefused(refusalOf(thrown, t))
290
- }
291
- }
292
- const submit = (event: FormEvent<HTMLFormElement>) => {
293
- // The dialog is portaled out of the document, and React still carries its
294
- // submit up to the page's own form, which would save the row.
295
- event.preventDefault()
296
- event.stopPropagation()
297
- void send()
298
- }
299
- const percent = Math.round((progress ?? 0) * 100)
300
- const title = t('admin.upload.new', 'Upload new') ?? 'Upload new'
301
- return (
302
- <Dialog open={open} onOpenChange={(next: boolean) => (next ? onOpenChange(true) : close())}>
303
- <DialogContent showCloseButton={false} data-upload-dialog={target.name}>
304
- <DialogHeader>
305
- <DialogTitle>{title}</DialogTitle>
306
- <DialogDescription>
307
- {(t('admin.upload.takes', 'Files of these types, {size} at most: {types}.') ?? '')
308
- .replace('{size}', () => sizeOf(target.storage?.fileSize ?? 0, locale))
309
- .replace('{types}', () => listed(typeNames(target.storage?.mimeTypes ?? []), locale))}
310
- </DialogDescription>
311
- </DialogHeader>
312
- <form className="grid min-w-0 gap-4" noValidate onSubmit={submit}>
313
- <FieldShell id={inputId} label={t('admin.upload.file', 'File') ?? 'File'} error={noFile ? (t('admin.upload.required', 'Choose a file first.') ?? 'Choose a file first.') : undefined}>
314
- {/* The browser's own file box draws its button in the browser's language, so the box is kept for the keyboard and the picker and the button is drawn in the page's. */}
315
- <div className="flex min-w-0 items-center gap-3">
316
- <input
317
- id={inputId}
318
- type="file"
319
- className="peer sr-only"
320
- accept={target.storage?.mimeTypes.join(',')}
321
- disabled={busy}
322
- aria-required
323
- aria-invalid={noFile || undefined}
324
- aria-describedby={describedBy({ id: inputId, error: noFile ? 'error' : undefined })}
325
- onChange={(event) => {
326
- const chosen = event.target.files?.[0] ?? null
327
- setFile(chosen)
328
- if (chosen) setNoFile(false)
329
- const early = chosen ? refusedFile(chosen, target.storage) : undefined
330
- setRefused(early ? { words: t(early, early) ?? early, retry: false } : undefined)
331
- }}
332
- />
333
- <label htmlFor={inputId} aria-hidden="true" className={buttonVariants({ variant: 'outline', className: 'shrink-0 cursor-pointer peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:pointer-events-none peer-disabled:opacity-50' })}>
334
- {t('admin.upload.choose', 'Choose a file') ?? 'Choose a file'}
335
- </label>
336
- <span data-upload-chosen="" dir="auto" className="text-muted-foreground min-w-0 truncate text-sm">
337
- {file?.name ?? t('admin.upload.noFile', 'No file chosen')}
338
- </span>
339
- </div>
340
- </FieldShell>
341
- {fields.map((field) => {
342
- const Control = controlFor(field)
343
- const id = `upload-${target.name}-${field.name}`
344
- const localized = field.rules.localized === true
345
- const held = (values[field.name] && typeof values[field.name] === 'object' ? values[field.name] : {}) as Row
346
- const names = new Intl.DisplayNames([locale], { type: 'language' })
347
- const error = wrong.words[field.name]
348
- const marked = (one: string | undefined) => error !== undefined && (!one || !wrong.locales[field.name]?.length || wrong.locales[field.name]!.includes(one))
349
- const set = (next: unknown) => {
350
- setValues((already) => ({ ...already, [field.name]: next }))
351
- setWrong(({ words: { [field.name]: _said, ...words }, locales }) => ({ words, locales }))
352
- }
353
- const control = (one: string | undefined) => (
354
- <Control
355
- key={one ?? ''}
356
- field={field}
357
- kayan={target}
358
- t={t}
359
- locale={locale}
360
- id={one && one !== locale ? `${id}-${one}` : id}
361
- value={one ? held[one] : values[field.name]}
362
- values={values}
363
- setValue={() => {}}
364
- onChange={(next) => set(one ? { ...held, [one]: next } : next)}
365
- readOnly={busy}
366
- error={marked(one) ? error : undefined}
367
- describedBy={[one ? `${id}-${one}-locale` : '', describedBy({ id, error }) ?? ''].filter(Boolean).join(' ') || undefined}
368
- />
369
- )
370
- const label = labelFor({ kayan: target, field, copy: { t, locale } })
371
- return (
372
- <FieldShell key={field.name} id={id} label={label} error={error}>
373
- {localized ? (
374
- // One box per locale, the viewer's own first. The viewer's box is named by the field's label, the others by label and locale, so none is unnamed.
375
- <div className="grid gap-2">
376
- {[locale, ...locales.filter((one) => one !== locale)].map((one) => (
377
- <div key={one} className="grid gap-1">
378
- {one === locale ? (
379
- <span id={`${id}-${one}-locale`} className="text-muted-foreground text-xs">{names.of(one)}</span>
380
- ) : (
381
- <label id={`${id}-${one}-locale`} htmlFor={`${id}-${one}`} className="text-muted-foreground text-xs">
382
- <span className="sr-only">{label} </span>
383
- {names.of(one)}
384
- </label>
385
- )}
386
- {control(one)}
387
- </div>
388
- ))}
389
- </div>
390
- ) : control(undefined)}
391
- </FieldShell>
392
- )
393
- })}
394
- {busy ? (
395
- <div role="status" className="grid gap-1 text-sm">
396
- <span>{(t('admin.upload.sending', 'Uploading… {percent}%') ?? '').replace('{percent}', () => percent.toLocaleString(locale))}</span>
397
- <progress value={progress ?? 0} max={1} aria-label={title} className="h-2 w-full" />
398
- </div>
399
- ) : null}
400
- {refused ? (
401
- <div role="alert" data-upload-refused="" className="border-destructive/50 text-destructive grid gap-2 rounded-md border px-3 py-2 text-sm">
402
- <p dir="auto">{refused.words}</p>
403
- {file && refused.retry ? (
404
- <Button type="button" variant="outline" size="sm" className="justify-self-start" onClick={() => void send()}>
405
- {t('admin.upload.retry', 'Try again') ?? 'Try again'}
406
- </Button>
407
- ) : null}
408
- </div>
409
- ) : null}
410
- <DialogFooter>
411
- <Button type="button" variant="outline" disabled={busy} onClick={close}>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</Button>
412
- <Button type="submit" disabled={busy}>
413
- <UploadIcon className="size-4" aria-hidden />
414
- {t('admin.upload.send', 'Upload') ?? 'Upload'}
415
- </Button>
416
- </DialogFooter>
417
- </form>
418
- </DialogContent>
419
- </Dialog>
420
- )
421
- }
422
-
423
- /**
424
- * The `upload` kind's control: one file as its thumbnail (or name and type) over a picker and "Upload new".
425
- * A set is a grid, each file removed or moved by a keyboard-reachable button; read-only shows the files alone.
426
- */
427
- export function UploadControl(props: ControlProps): ReactElement {
428
- const { api, client, field, id, locale = 'en', locales, readOnly, t, value } = props
429
- const change = changeOf(props)
430
- const screen = use(ScreenContext)
431
- const target = client?.find((one) => one.name === field.relationTo)
432
- const reads = target && api ? api[target.name] : undefined
433
- const held = idsOf(value)
434
- const many = field.hasMany === true
435
- const [rows, setRows] = useState<ReadonlyMap<string, Row>>(() => new Map())
436
- const [uploading, setUploading] = useState(false)
437
- const [focus, setFocus] = useState<string>()
438
- const asked = useRef(new Set<string>())
439
- const [settled, setSettled] = useState<ReadonlyMap<string, 'unseen' | 'failed'>>(() => new Map())
440
-
441
- const excluded = held.join(',')
442
- useEffect(() => {
443
- if (!reads || !screen) return
444
- const missing = held.filter((one) => !rows.has(one) && !asked.current.has(one))
445
- if (!missing.length) return
446
- for (const one of missing) asked.current.add(one)
447
- void findByIds({ reads, ids: missing, maxLimit: screen.screen.maxLimit })
448
- .then((docs) => {
449
- const found = new Set(docs.map((row) => String(row.id ?? '')))
450
- setSettled((already) => new Map([...already, ...missing.filter((one) => !found.has(one)).map((one) => [one, 'unseen'] as const)]))
451
- setRows((already) => new Map([...already, ...docs.map((row) => [String(row.id ?? ''), row] as const)]))
452
- })
453
- .catch((thrown: unknown) => {
454
- const state = isForbidden(thrown) ? 'unseen' : 'failed'
455
- setSettled((already) => new Map([...already, ...missing.map((one) => [one, state] as const)]))
456
- })
457
- }, [excluded, reads])
458
-
459
- useEffect(() => {
460
- if (!focus) return
461
- document.getElementById(focus)?.focus()
462
- setFocus(undefined)
463
- }, [focus])
464
-
465
- /** One held file: its thumbnail, or what the read settled, and never its raw id. */
466
- const drawn = (one: string, size: keyof typeof sizes) => {
467
- const row = rows.get(one)
468
- if (row) return <FileThumb row={row} t={t} locale={locale} size={size} />
469
- const state = reads && screen ? settled.get(one) : 'unseen'
470
- if (state === 'failed') return <span>—</span>
471
- return <span className="text-muted-foreground text-xs">{state === 'unseen' ? (t('admin.cannotSee', 'You can\'t see this') ?? 'You can\'t see this') : (t('admin.loading', 'Loading…') ?? 'Loading…')}</span>
472
- }
473
- // Offered only where the access report lets this actor create a file.
474
- const creates = target !== undefined && createsIn({ kayan: target, report: screen?.access.collections?.[target.name] })
475
- const upload = !readOnly && target?.storage && screen && creates ? (
476
- <>
477
- <Button type="button" variant="outline" className="max-sm:h-11" onClick={() => setUploading(true)} data-upload-new="">
478
- <UploadIcon className="size-4" aria-hidden />
479
- {t('admin.upload.new', 'Upload new') ?? 'Upload new'}
480
- </Button>
481
- <UploadDialog
482
- open={uploading}
483
- onOpenChange={setUploading}
484
- target={target}
485
- client={screen.client}
486
- t={t}
487
- locale={locale}
488
- locales={locales ?? [locale]}
489
- onDone={(row) => {
490
- const made = String(row.id ?? '')
491
- asked.current.add(made)
492
- setRows((already) => new Map(already).set(made, row))
493
- change(many ? [...held, made] : made)
494
- }}
495
- />
496
- </>
497
- ) : null
498
- const picker = (single: boolean) => (
499
- <div className="min-w-0 flex-1 basis-48">
500
- <RelationshipControl
501
- {...props}
502
- field={{ ...field, hasMany: undefined }}
503
- value={single ? value : null}
504
- onChange={(next) => {
505
- if (single) return change(next)
506
- if (typeof next === 'string' && !held.includes(next)) change([...held, next])
507
- }}
508
- placeholder={single ? (t('admin.upload.choose', 'Choose a file') ?? 'Choose a file') : (t('admin.upload.add', 'Add a file') ?? 'Add a file')}
509
- drawRow={(row, label) => (
510
- <span className="flex min-w-0 items-center gap-2">
511
- {urlOf(row, 'thumbnailURL') ? <FileThumb row={row} t={t} locale={locale} size="xs" /> : <FileIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />}
512
- <span className="min-w-0 truncate" dir="auto">{label}</span>
513
- </span>
514
- )}
515
- />
516
- </div>
517
- )
518
-
519
- if (!many) {
520
- return (
521
- <div className="grid min-w-0 gap-2" data-upload-control={field.name}>
522
- {held[0] ? drawn(held[0], readOnly ? 'lg' : 'sm') : null}
523
- {readOnly ? null : (
524
- <div className="flex min-w-0 flex-wrap items-center gap-2">
525
- {picker(true)}
526
- {upload}
527
- </div>
528
- )}
529
- </div>
530
- )
531
- }
532
-
533
- // The keyboard stays where the person was: on the button that moved the file (its tile's other move once it reaches an end), or on a neighbour's remove after a removal, else the picker.
534
- const buttonOf = (one: string, which: 'earlier' | 'later' | 'remove') => `${id}-${one}-${which}`
535
- const move = (from: number, by: -1 | 1) => {
536
- const next = [...held]
537
- const [one] = next.splice(from, 1)
538
- next.splice(from + by, 0, one!)
539
- change(next)
540
- const end = by === -1 ? from + by === 0 : from + by === next.length - 1
541
- setFocus(buttonOf(one!, by === -1 ? (end ? 'later' : 'earlier') : end ? 'earlier' : 'later'))
542
- }
543
- const remove = (at: number) => {
544
- const next = held.filter((_, each) => each !== at)
545
- change(next)
546
- const neighbour = held[at + 1] ?? held[at - 1]
547
- setFocus(neighbour ? buttonOf(neighbour, 'remove') : id)
548
- }
549
- const words = (key: string, fallback: string, name: string) => (t(key, fallback) ?? fallback).replace('{name}', () => name)
550
- return (
551
- <div className="grid min-w-0 gap-2" data-upload-control={field.name}>
552
- {held.length ? (
553
- <ul className="grid grid-cols-[repeat(auto-fill,minmax(6rem,1fr))] gap-2">
554
- {held.map((one, at) => {
555
- const name = String(rows.get(one)?.filename ?? one)
556
- const earlier = words('admin.upload.moveEarlier', 'Move {name} earlier', name)
557
- const later = words('admin.upload.moveLater', 'Move {name} later', name)
558
- const removeLabel = words('admin.upload.remove', 'Remove {name}', name)
559
- return (
560
- <li key={one} data-upload-tile={one} className="grid min-w-0 justify-items-center gap-1 rounded-md border p-1">
561
- {drawn(one, 'sm')}
562
- {readOnly ? null : (
563
- <div className="flex">
564
- <Button id={buttonOf(one, 'earlier')} type="button" variant="ghost" size="icon-sm" disabled={at === 0} title={earlier} aria-label={earlier} onClick={() => move(at, -1)}>
565
- <ChevronLeftIcon className="size-4 rtl:-scale-x-100" aria-hidden />
566
- </Button>
567
- <Button id={buttonOf(one, 'remove')} type="button" variant="ghost" size="icon-sm" title={removeLabel} aria-label={removeLabel} onClick={() => remove(at)}>
568
- <XIcon className="size-4" aria-hidden />
569
- </Button>
570
- <Button id={buttonOf(one, 'later')} type="button" variant="ghost" size="icon-sm" disabled={at === held.length - 1} title={later} aria-label={later} onClick={() => move(at, 1)}>
571
- <ChevronRightIcon className="size-4 rtl:-scale-x-100" aria-hidden />
572
- </Button>
573
- </div>
574
- )}
575
- </li>
576
- )
577
- })}
578
- </ul>
579
- ) : null}
580
- {readOnly ? null : (
581
- <div className="flex min-w-0 flex-wrap items-center gap-2">
582
- {picker(false)}
583
- {upload}
584
- </div>
585
- )}
586
- </div>
587
- )
588
- }
589
-
590
- // The rich text node is opt-in: a project lists `UploadNode` from `mithaq/rich-text` among its nodes,
591
- // `UploadView` among its views, `imagePick` among its picks and `UploadPlugin` as the editor's plugin.
592
-
593
- /** What the `/` menu's "Image" asks the editor for, which `UploadPlugin` answers with its dialog. */
594
- export const INSERT_UPLOAD_COMMAND = createCommand<void>('INSERT_UPLOAD_COMMAND')
595
-
596
- /** The `/` menu's "Image", in each locale's own words. */
597
- export const imagePick: RichTextPick = {
598
- key: 'upload',
599
- label: { en: 'Image', ar: 'صورة' },
600
- Icon: ImageIcon,
601
- keywords: ['image', 'upload', 'picture', 'photo', 'صورة'],
602
- onSelect: ({ editor }) => editor.dispatchCommand(INSERT_UPLOAD_COMMAND, undefined),
603
- }
604
-
605
- /**
606
- * A file inside the text, read by its id through the screen's client so the row's own access
607
- * decides, and drawn off the row's computed `url`. A file the viewer may not read, or of a kayan this screen holds no files of, draws the placeholder.
608
- */
609
- function UploadImage({ relationTo, value, format }: { relationTo: string; value: string; format: string }): ReactElement {
610
- const screen = use(ScreenContext)
611
- const t: Translate = screen?.t ?? ((_key, fallback) => fallback)
612
- const locale = screen?.locale ?? 'en'
613
- const readable = !!screen?.screen.kayan[relationTo]?.storage
614
- const [row, setRow] = useState<Row | null | false>(readable ? null : false)
615
- useEffect(() => {
616
- if (!screen || !readable) return setRow(false)
617
- let live = true
618
- screen.client({ kayan: relationTo, operation: 'find', input: { where: { id: { equals: value } }, limit: 1 } })
619
- .then((found) => live && setRow((found as FoundPage).docs[0] ?? false), () => live && setRow(false))
620
- return () => {
621
- live = false
622
- }
623
- }, [relationTo, value, readable, screen?.client])
624
- const align = format === 'center' ? 'justify-center' : format === 'right' || format === 'end' ? 'justify-end' : 'justify-start'
625
- return (
626
- <div data-upload-node={value} className={`my-2 flex ${align}`}>
627
- {row === null ? <span className="bg-muted size-24 animate-pulse rounded-md" /> : row ? <FileValue row={row} t={t} locale={locale} /> : <Missing t={t} size="lg" />}
628
- </div>
629
- )
630
- }
631
-
632
- /** The node's React half: `UploadNode`'s type and data, drawn by `UploadImage`. */
633
- export class UploadView extends UploadNode {
634
- $config() {
635
- return this.config('upload', { extends: UploadNode })
636
- }
637
-
638
- decorate(): ReactElement {
639
- return <UploadImage relationTo={this.getRelationTo()} value={this.getValue()} format={this.getFormat()} />
640
- }
641
- }
642
-
643
- /**
644
- * The editor's half of "Image": a dialog holding the upload control over the screen's first kayan that holds files,
645
- * so a file is chosen or uploaded as a field's is; the node goes in its own block where the selection was.
646
- */
647
- export function UploadPlugin(props: ControlProps): ReactElement | null {
648
- const [editor] = useLexicalComposerContext()
649
- const screen = use(ScreenContext)
650
- const [open, setOpen] = useState(false)
651
- useEffect(() => editor.registerCommand(INSERT_UPLOAD_COMMAND, () => (setOpen(true), true), COMMAND_PRIORITY_EDITOR), [editor])
652
- const target = Object.values(screen?.screen.kayan ?? {}).find((one) => one.storage)
653
- if (!screen || !target) return null
654
- const { t } = props
655
- const api: FindApi = { [target.name]: { find: (input) => screen.client({ kayan: target.name, operation: 'find', input }) as Promise<FoundPage> } }
656
- const field: ClientField = { name: 'upload', kind: 'upload', relationTo: target.name, rules: {}, component: 'upload', cell: 'upload', label: { [props.locale ?? 'en']: t('admin.upload.image', 'Image') ?? 'Image' } }
657
- return (
658
- <Dialog open={open} onOpenChange={setOpen}>
659
- <DialogContent showCloseButton={false} data-upload-insert="">
660
- <DialogHeader>
661
- <DialogTitle>{t('admin.upload.insert', 'Insert an image') ?? 'Insert an image'}</DialogTitle>
662
- <DialogDescription>{t('admin.upload.insertHint', 'Choose a file already here, or upload a new one.') ?? ''}</DialogDescription>
663
- </DialogHeader>
664
- <UploadControl
665
- {...props}
666
- id={`${props.id}-upload`}
667
- field={field}
668
- kayan={target}
669
- client={[target, ...(props.client ?? [])]}
670
- api={api}
671
- value={null}
672
- readOnly={false}
673
- onChange={(next) => {
674
- if (typeof next !== 'string') return
675
- editor.update(() => void $insertNodeToNearestRoot($create(UploadView).setUpload(target.name, next)))
676
- setOpen(false)
677
- }}
678
- />
679
- <DialogFooter>
680
- <Button type="button" variant="outline" onClick={() => setOpen(false)}>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</Button>
681
- </DialogFooter>
682
- </DialogContent>
683
- </Dialog>
684
- )
685
- }
686
- // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 a7c10956534bbee3e398be2e6b514112d36145157e1fb4ad9ea439c5853a3854