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,1083 @@
1
+ import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type JSX, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { LexicalComposer } from '@lexical/react/LexicalComposer'
4
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
5
+ import { ContentEditable } from '@lexical/react/LexicalContentEditable'
6
+ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
7
+ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
8
+ import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
9
+ import { ListPlugin } from '@lexical/react/LexicalListPlugin'
10
+ import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
11
+ import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'
12
+ import { HorizontalRuleNode as HorizontalRuleView, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
13
+ import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
14
+ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
15
+ import { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
16
+ import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'
17
+ import { Input } from '@/components/ui/input'
18
+ import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode } from '@lexical/list'
19
+ import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, type HeadingTagType } from '@lexical/rich-text'
20
+ import { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'
21
+ import { $dfs, $findMatchingParent, $getNearestBlockElementAncestorOrThrow, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
22
+ import {
23
+ $createParagraphNode,
24
+ $getRoot,
25
+ createEditor,
26
+ $getSelection,
27
+ $isElementNode,
28
+ $isLineBreakNode,
29
+ $isNodeSelection,
30
+ $isRangeSelection,
31
+ $isTextNode,
32
+ BLUR_COMMAND,
33
+ CAN_REDO_COMMAND,
34
+ CAN_UNDO_COMMAND,
35
+ COMMAND_PRIORITY_CRITICAL,
36
+ COMMAND_PRIORITY_HIGH,
37
+ COMMAND_PRIORITY_LOW,
38
+ FORMAT_ELEMENT_COMMAND,
39
+ FORMAT_TEXT_COMMAND,
40
+ HISTORY_MERGE_TAG,
41
+ INDENT_CONTENT_COMMAND,
42
+ KEY_ESCAPE_COMMAND,
43
+ OUTDENT_CONTENT_COMMAND,
44
+ REDO_COMMAND,
45
+ SELECTION_CHANGE_COMMAND,
46
+ UNDO_COMMAND,
47
+ type BaseSelection,
48
+ type ElementFormatType,
49
+ type EditorState,
50
+ type ElementNode,
51
+ type Klass,
52
+ type LexicalEditor,
53
+ type LexicalNode,
54
+ type RangeSelection,
55
+ type TextFormatType,
56
+ type TextNode,
57
+ } from 'lexical'
58
+ import {
59
+ CheckIcon,
60
+ PencilIcon,
61
+ TrashIcon,
62
+ XIcon,
63
+ AlignCenterIcon,
64
+ AlignJustifyIcon,
65
+ AlignLeftIcon,
66
+ AlignRightIcon,
67
+ BoldIcon,
68
+ ChevronDownIcon,
69
+ CodeIcon,
70
+ EraserIcon,
71
+ Heading1Icon,
72
+ Heading2Icon,
73
+ Heading3Icon,
74
+ IndentDecreaseIcon,
75
+ IndentIncreaseIcon,
76
+ ItalicIcon,
77
+ LinkIcon,
78
+ ListIcon,
79
+ ListOrderedIcon,
80
+ ListTodoIcon,
81
+ MinusIcon,
82
+ QuoteIcon,
83
+ RedoIcon,
84
+ StrikethroughIcon,
85
+ TextIcon,
86
+ UnderlineIcon,
87
+ UndoIcon,
88
+ } from 'lucide-react'
89
+ import { useDirection } from '@/components/ui/direction'
90
+ import { Button } from '@/components/ui/button'
91
+ import { ButtonGroup } from '@/components/ui/button-group'
92
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
93
+ import { Toggle } from '@/components/ui/toggle'
94
+ import { cn } from '@/lib/utils'
95
+ import { ariaRequired, changeOf, type ControlProps, type Translate } from './controls.tsx'
96
+ import { richTextBounds, safeUrl, typesOf, unknownType } from 'mithaq/rich-text'
97
+ import { defaultNodes } from './rich-text-nodes.ts'
98
+
99
+ /** One node of Lexical's serialized editor state. */
100
+ export type RichTextNode = { type: string; children?: RichTextNode[]; text?: string; [key: string]: unknown }
101
+ /** What a `richText` field holds. */
102
+ export type RichTextValue = { root: RichTextNode & { type: 'root'; children: RichTextNode[] }; [key: string]: unknown }
103
+ export type RichTextDirection = 'ltr' | 'rtl'
104
+
105
+ /** One empty paragraph, with no direction of its own so the block takes the one its first letter gives it. */
106
+ const emptyValue = (): RichTextValue => ({
107
+ root: {
108
+ children: [{ children: [], direction: null, format: '', indent: 0, textFormat: 0, textStyle: '', type: 'paragraph', version: 1 }],
109
+ direction: null,
110
+ format: '',
111
+ indent: 0,
112
+ type: 'root',
113
+ version: 1,
114
+ },
115
+ })
116
+
117
+ const valueOf = (value: unknown): RichTextValue => {
118
+ const held = value && typeof value === 'object' && (value as RichTextValue).root ? (value as RichTextValue) : undefined
119
+ return held?.root.children?.length ? held : emptyValue()
120
+ }
121
+
122
+ /** Whether the editor holds nothing at all: only empty paragraphs, which the control reports as `null`. */
123
+ const blank = (value: RichTextValue) => value.root.children.every((node) => node.type === 'paragraph' && !node.children?.length)
124
+
125
+ const theme = {
126
+ paragraph: 'mb-2',
127
+ heading: {
128
+ h1: 'mb-3 text-2xl font-semibold',
129
+ h2: 'mb-3 text-xl font-semibold',
130
+ h3: 'mb-2 text-lg font-semibold',
131
+ },
132
+ list: {
133
+ nested: { listitem: 'list-none before:hidden after:hidden' },
134
+ ol: 'ms-6 list-decimal',
135
+ ul: 'ms-6 list-disc',
136
+ checklist: 'ms-2 list-none',
137
+ listitem: 'mb-1',
138
+ listitemChecked:
139
+ 'relative mb-1 list-none px-7 line-through opacity-70 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:bg-primary after:absolute after:start-[9px] after:top-[5px] after:block after:h-2 after:w-1 after:rotate-45 after:border-b-2 after:border-e-2 after:border-white after:content-[""]',
140
+ listitemUnchecked:
141
+ 'relative mb-1 list-none px-7 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:content-[""]',
142
+ },
143
+ quote: 'mb-2 border-s-2 ps-3 text-muted-foreground',
144
+ link: 'text-primary underline underline-offset-4',
145
+ hr: 'my-3 border-t',
146
+ text: {
147
+ bold: 'font-semibold',
148
+ italic: 'italic',
149
+ underline: 'underline underline-offset-2',
150
+ strikethrough: 'line-through',
151
+ underlineStrikethrough: 'underline line-through',
152
+ code: 'rounded bg-muted px-1 font-mono text-[0.9em]',
153
+ },
154
+ }
155
+
156
+ /** What a toolbar or a slash entry runs with: the editor, and whether the entry is the block the selection is in. */
157
+ type Selected = { editor: LexicalEditor; isActive: boolean }
158
+
159
+ /**
160
+ * One entry of the `/` menu, and of the block menu where `block: true`: the block type it makes,
161
+ * its words in each locale, its icon, and what it does. A project's own node brings its own pick beside `defaultPicks`.
162
+ */
163
+ export type RichTextPick = {
164
+ key: string
165
+ label: Readonly<Record<string, string>>
166
+ Icon: ComponentType<{ className?: string }>
167
+ keywords?: readonly string[]
168
+ block?: true
169
+ onSelect: (selected: Selected) => void
170
+ }
171
+
172
+ const pickLabel = (pick: RichTextPick, locale: string) => pick.label[locale] ?? pick.label.en ?? pick.key
173
+
174
+ type Toolbar = { activeEditor: LexicalEditor; blockType: string; setBlockType: (blockType: string) => void }
175
+
176
+ /** Runs `callback` on every selection change and once on mount. */
177
+ function useSelectionChange(activeEditor: LexicalEditor, callback: (selection: BaseSelection) => void) {
178
+ const latest = useRef(callback)
179
+ latest.current = callback
180
+ useEffect(
181
+ () =>
182
+ activeEditor.registerCommand(
183
+ SELECTION_CHANGE_COMMAND,
184
+ () => {
185
+ const selection = $getSelection()
186
+ if (selection) latest.current(selection)
187
+ return false
188
+ },
189
+ COMMAND_PRIORITY_CRITICAL,
190
+ ),
191
+ [activeEditor],
192
+ )
193
+ useEffect(() => {
194
+ activeEditor.getEditorState().read(() => {
195
+ const selection = $getSelection()
196
+ if (selection) latest.current(selection)
197
+ })
198
+ }, [activeEditor])
199
+ }
200
+
201
+ function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
202
+ const anchorNode = selection.anchor.getNode()
203
+ const focusNode = selection.focus.getNode()
204
+ if (anchorNode === focusNode) return anchorNode
205
+ return selection.isBackward()
206
+ ? ($isAtNodeEnd(selection.focus) ? anchorNode : focusNode)
207
+ : ($isAtNodeEnd(selection.anchor) ? anchorNode : focusNode)
208
+ }
209
+
210
+ const toParagraph = (editor: LexicalEditor) =>
211
+ editor.update(() => {
212
+ const selection = $getSelection()
213
+ if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createParagraphNode())
214
+ })
215
+
216
+ const toHeading = (editor: LexicalEditor, tag: HeadingTagType) =>
217
+ editor.update(() => {
218
+ const selection = $getSelection()
219
+ if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createHeadingNode(tag))
220
+ })
221
+
222
+ const listOf = (command: typeof INSERT_UNORDERED_LIST_COMMAND) => ({ editor, isActive }: Selected) =>
223
+ isActive ? toParagraph(editor) : editor.dispatchCommand(command, undefined)
224
+
225
+ function HistoryButtons({ activeEditor, t }: { activeEditor: LexicalEditor; t: Translate }) {
226
+ const [canUndo, setCanUndo] = useState(false)
227
+ const [canRedo, setCanRedo] = useState(false)
228
+ useEffect(
229
+ () =>
230
+ mergeRegister(
231
+ activeEditor.registerCommand<boolean>(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_CRITICAL),
232
+ activeEditor.registerCommand<boolean>(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_CRITICAL),
233
+ ),
234
+ [activeEditor],
235
+ )
236
+ const undo = t('admin.richText.undo', 'Undo') ?? 'Undo'
237
+ const redo = t('admin.richText.redo', 'Redo') ?? 'Redo'
238
+ return (
239
+ <ButtonGroup>
240
+ <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={undo} aria-label={undo} disabled={!canUndo} onClick={() => activeEditor.dispatchCommand(UNDO_COMMAND, undefined)}>
241
+ <UndoIcon className="size-4 rtl:-scale-x-100" />
242
+ </Button>
243
+ <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={redo} aria-label={redo} disabled={!canRedo} onClick={() => activeEditor.dispatchCommand(REDO_COMMAND, undefined)}>
244
+ <RedoIcon className="size-4 rtl:-scale-x-100" />
245
+ </Button>
246
+ </ButtonGroup>
247
+ )
248
+ }
249
+
250
+ const nearestBlock = (anchorNode: LexicalNode) =>
251
+ $isElementNode(anchorNode) && !anchorNode.isInline() ? anchorNode : $findMatchingParent(anchorNode, (node) => $isElementNode(node) && !node.isInline())
252
+
253
+ function BlockDropdown({ toolbar, t, locale, picks }: { toolbar: Toolbar; t: Translate; locale: string; picks: readonly RichTextPick[] }) {
254
+ const { activeEditor, blockType, setBlockType } = toolbar
255
+ useSelectionChange(activeEditor, (selection) => {
256
+ if (!$isRangeSelection(selection)) return
257
+ const anchorNode = selection.anchor.getNode()
258
+ const element = nearestBlock(anchorNode) ?? anchorNode.getTopLevelElementOrThrow()
259
+ if (activeEditor.getElementByKey(element.getKey()) === null) return
260
+ if ($isListNode(element)) {
261
+ const parent = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
262
+ setBlockType((parent ?? element).getListType())
263
+ } else {
264
+ setBlockType($isHeadingNode(element) ? element.getTag() : element.getType())
265
+ }
266
+ })
267
+ const blocks = picks.filter((pick) => pick.block)
268
+ const current = blocks.find((entry) => entry.key === blockType) ?? blocks[0]
269
+ if (!current) return null
270
+ // The accessible name contains the visible words, so a voice control can say them to press the button.
271
+ const name = (t('admin.richText.blockFormatCurrent', 'Block format: {block}') ?? 'Block format: {block}').replace('{block}', () => pickLabel(current, locale))
272
+ return (
273
+ <DropdownMenu>
274
+ <DropdownMenuTrigger render={<Button type="button" variant="outline" size="sm" className="gap-1 px-2 max-sm:h-11" title={name} aria-label={name} />}>
275
+ <current.Icon className="size-4" />
276
+ <span className="text-sm">{pickLabel(current, locale)}</span>
277
+ <ChevronDownIcon className="size-3" />
278
+ </DropdownMenuTrigger>
279
+ <DropdownMenuContent>
280
+ {blocks.map((pick) => (
281
+ <DropdownMenuItem key={pick.key} onClick={() => pick.onSelect({ editor: activeEditor, isActive: pick.key === blockType })}>
282
+ <div className="flex items-center gap-1 font-normal">
283
+ <pick.Icon className="size-4" />
284
+ {pickLabel(pick, locale)}
285
+ </div>
286
+ </DropdownMenuItem>
287
+ ))}
288
+ </DropdownMenuContent>
289
+ </DropdownMenu>
290
+ )
291
+ }
292
+
293
+ const formats = [
294
+ { format: 'bold', Icon: BoldIcon, label: (t: Translate) => t('admin.richText.bold', 'Bold') },
295
+ { format: 'italic', Icon: ItalicIcon, label: (t: Translate) => t('admin.richText.italic', 'Italic') },
296
+ { format: 'underline', Icon: UnderlineIcon, label: (t: Translate) => t('admin.richText.underline', 'Underline') },
297
+ { format: 'strikethrough', Icon: StrikethroughIcon, label: (t: Translate) => t('admin.richText.strikethrough', 'Strikethrough') },
298
+ { format: 'code', Icon: CodeIcon, label: (t: Translate) => t('admin.richText.code', 'Code') },
299
+ ] as const
300
+ function FormatButtons({ t }: { t: Translate }) {
301
+ const [editor] = useLexicalComposerContext()
302
+ const [active, setActive] = useState<readonly string[]>([])
303
+ useSelectionChange(editor, (selection) => {
304
+ if ($isRangeSelection(selection)) setActive(formats.filter(({ format }) => selection.hasFormat(format)).map(({ format }) => format))
305
+ })
306
+ return (
307
+ <ButtonGroup>
308
+ {formats.map(({ format, Icon, label }) => {
309
+ const name = label(t) ?? format
310
+ return (
311
+ <Toggle key={format} variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={active.includes(format)} onPressedChange={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format as TextFormatType)}>
312
+ <Icon className="size-4" />
313
+ </Toggle>
314
+ )
315
+ })}
316
+ </ButtonGroup>
317
+ )
318
+ }
319
+
320
+ function ClearFormatting({ t }: { t: Translate }) {
321
+ const [editor] = useLexicalComposerContext()
322
+ const clear = useCallback(() => {
323
+ editor.update(() => {
324
+ const selection = $getSelection()
325
+ if (!$isRangeSelection(selection)) return
326
+ const { anchor, focus } = selection
327
+ const nodes = selection.getNodes()
328
+ const extractedNodes = selection.extract()
329
+ if (anchor.key === focus.key && anchor.offset === focus.offset) return
330
+ nodes.forEach((node, idx) => {
331
+ // The first and last node are split by the selection, so text outside
332
+ // it keeps its format.
333
+ if ($isTextNode(node)) {
334
+ let textNode = node
335
+ if (idx === 0 && anchor.offset !== 0) textNode = textNode.splitText(anchor.offset)[1] || textNode
336
+ if (idx === nodes.length - 1) textNode = textNode.splitText(focus.offset)[0] || textNode
337
+ const extractedTextNode = extractedNodes[0]
338
+ if (nodes.length === 1 && $isTextNode(extractedTextNode)) textNode = extractedTextNode
339
+ if (textNode.__style !== '') textNode.setStyle('')
340
+ if (textNode.__format !== 0) {
341
+ textNode.setFormat(0)
342
+ $getNearestBlockElementAncestorOrThrow(textNode).setFormat('')
343
+ }
344
+ } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
345
+ node.replace($createParagraphNode(), true)
346
+ }
347
+ })
348
+ })
349
+ }, [editor])
350
+ const name = t('admin.richText.clearFormatting', 'Clear formatting') ?? 'Clear formatting'
351
+ return (
352
+ <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={name} aria-label={name} onClick={clear}>
353
+ <EraserIcon className="size-4" />
354
+ </Button>
355
+ )
356
+ }
357
+
358
+ function LinkButton({ t, setLinkEditMode }: { t: Translate; setLinkEditMode: (editing: boolean) => void }) {
359
+ const [editor] = useLexicalComposerContext()
360
+ const [isLink, setIsLink] = useState(false)
361
+ useSelectionChange(editor, (selection) => {
362
+ if (!$isRangeSelection(selection)) return
363
+ const node = getSelectedNode(selection)
364
+ setIsLink($isLinkNode(node.getParent()) || $isLinkNode(node))
365
+ })
366
+ const name = isLink ? (t('admin.richText.removeLink', 'Remove link') ?? 'Remove link') : (t('admin.richText.addLink', 'Add link') ?? 'Add link')
367
+ return (
368
+ <Toggle variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={isLink} onPressedChange={() => {
369
+ setLinkEditMode(!isLink)
370
+ // `LinkPlugin`'s own check refuses the placeholder, so it is made past it; `dropPlaceholders` removes it once the link editor is no longer open on it.
371
+ if (isLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
372
+ else editor.update(() => $toggleLink(newLink))
373
+ }}>
374
+ <LinkIcon className="size-4" />
375
+ </Toggle>
376
+ )
377
+ }
378
+
379
+ const alignments = [
380
+ { format: 'left', Icon: AlignLeftIcon, label: (t: Translate) => t('admin.richText.alignLeft', 'Align left') },
381
+ { format: 'center', Icon: AlignCenterIcon, label: (t: Translate) => t('admin.richText.alignCenter', 'Align center') },
382
+ { format: 'right', Icon: AlignRightIcon, label: (t: Translate) => t('admin.richText.alignRight', 'Align right') },
383
+ { format: 'justify', Icon: AlignJustifyIcon, label: (t: Translate) => t('admin.richText.justify', 'Justify') },
384
+ ] as const
385
+ function AlignButtons({ t }: { t: Translate }) {
386
+ const [editor] = useLexicalComposerContext()
387
+ const [current, setCurrent] = useState<ElementFormatType>('')
388
+ useSelectionChange(editor, (selection) => {
389
+ if (!$isRangeSelection(selection)) return
390
+ const node = getSelectedNode(selection)
391
+ const parent = node.getParent()
392
+ // A link is inline, so a link's alignment is its paragraph's.
393
+ const block = $isLinkNode(parent) ? $findMatchingParent(node, (one) => $isElementNode(one) && !one.isInline()) : undefined
394
+ setCurrent($isElementNode(block) ? block.getFormatType() : $isElementNode(node) ? node.getFormatType() : (parent?.getFormatType() ?? ''))
395
+ })
396
+ const outdent = t('admin.richText.outdent', 'Outdent') ?? 'Outdent'
397
+ const indent = t('admin.richText.indent', 'Indent') ?? 'Indent'
398
+ return (
399
+ <>
400
+ <ButtonGroup>
401
+ {alignments.map(({ format, Icon, label }) => {
402
+ const name = label(t) ?? format
403
+ return (
404
+ <Toggle key={format} variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={current === format} onPressedChange={() => (setCurrent(format), editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format))}>
405
+ <Icon className="size-4" />
406
+ </Toggle>
407
+ )
408
+ })}
409
+ </ButtonGroup>
410
+ <ButtonGroup>
411
+ <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={outdent} aria-label={outdent} onClick={() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)}>
412
+ <IndentDecreaseIcon className="size-4 rtl:-scale-x-100" />
413
+ </Button>
414
+ <Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={indent} aria-label={indent} onClick={() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)}>
415
+ <IndentIncreaseIcon className="size-4 rtl:-scale-x-100" />
416
+ </Button>
417
+ </ButtonGroup>
418
+ </>
419
+ )
420
+ }
421
+
422
+ /** The address a new link starts with: the link editor opens on it so it is typed over, and no save may hold it. */
423
+ const newLink = 'https://'
424
+
425
+ /** Unwraps every link still holding the placeholder (except the selected one when `keepSelected`) back into its words, so a saved value holds none. */
426
+ const dropPlaceholders = (editor: LexicalEditor, keepSelected: boolean) =>
427
+ editor.update(() => {
428
+ const selection = $getSelection()
429
+ const kept = keepSelected && $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null
430
+ for (const { node } of $dfs()) {
431
+ if (!$isLinkNode(node) || node.getURL() !== newLink || node.is(kept)) continue
432
+ for (const child of node.getChildren()) node.insertBefore(child)
433
+ node.remove()
434
+ }
435
+ })
436
+
437
+ const holdsPlaceholder = (state: EditorState) => state.read(() => $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink))
438
+
439
+ /** Whether the focus is in the link editor's own box, where a placeholder is being typed over. */
440
+ const typingLink = () => document.activeElement?.classList.contains('link-input') === true
441
+
442
+ const VERTICAL_GAP = 10
443
+ const HORIZONTAL_OFFSET = 5
444
+
445
+ function setFloatingElemPositionForLinkEditor(targetRect: DOMRect | null, floatingElem: HTMLElement, anchorElem: HTMLElement): void {
446
+ const scrollerElem = anchorElem.parentElement
447
+ if (targetRect === null || !scrollerElem) {
448
+ floatingElem.style.opacity = '0'
449
+ floatingElem.style.transform = 'translate(-10000px, -10000px)'
450
+ return
451
+ }
452
+ const floatingElemRect = floatingElem.getBoundingClientRect()
453
+ const anchorElementRect = anchorElem.getBoundingClientRect()
454
+ const editorScrollerRect = scrollerElem.getBoundingClientRect()
455
+ let top = targetRect.top - VERTICAL_GAP
456
+ let left = targetRect.left - HORIZONTAL_OFFSET
457
+ if (top < editorScrollerRect.top) top += floatingElemRect.height + targetRect.height + VERTICAL_GAP * 2
458
+ if (left + floatingElemRect.width > editorScrollerRect.right) left = editorScrollerRect.right - floatingElemRect.width - HORIZONTAL_OFFSET
459
+ // A right-to-left page puts the bubble's start on the right, which the
460
+ // physical offset above would push off the edge.
461
+ if (left < editorScrollerRect.left) left = editorScrollerRect.left
462
+ top -= anchorElementRect.top
463
+ left -= anchorElementRect.left
464
+ floatingElem.style.opacity = '1'
465
+ floatingElem.style.transform = `translate(${left}px, ${top}px)`
466
+ }
467
+
468
+ function FloatingLinkEditor({ editor, isLink, setIsLink, anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {
469
+ editor: LexicalEditor
470
+ isLink: boolean
471
+ setIsLink: (isLink: boolean) => void
472
+ anchorElem: HTMLElement
473
+ isLinkEditMode: boolean
474
+ setIsLinkEditMode: (editing: boolean) => void
475
+ t: Translate
476
+ }): JSX.Element {
477
+ const editorRef = useRef<HTMLDivElement | null>(null)
478
+ const inputRef = useRef<HTMLInputElement>(null)
479
+ const [linkUrl, setLinkUrl] = useState('')
480
+ const [editedLinkUrl, setEditedLinkUrl] = useState(newLink)
481
+ // False until the address is edited, so the placeholder it opens on is not flagged invalid.
482
+ const [edited, setEdited] = useState(false)
483
+ const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)
484
+
485
+ const $updateLinkEditor = useCallback(() => {
486
+ const selection = $getSelection()
487
+ if ($isRangeSelection(selection)) {
488
+ const node = getSelectedNode(selection)
489
+ const linkParent = $findMatchingParent(node, $isLinkNode)
490
+ if (linkParent) setLinkUrl(linkParent.getURL())
491
+ else if ($isLinkNode(node)) setLinkUrl(node.getURL())
492
+ else setLinkUrl('')
493
+ if (isLinkEditMode) setEditedLinkUrl(linkUrl)
494
+ }
495
+ const editorElem = editorRef.current
496
+ const nativeSelection = window.getSelection()
497
+ const activeElement = document.activeElement
498
+ if (editorElem === null) return
499
+ const rootElement = editor.getRootElement()
500
+ if (selection !== null && nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode) && editor.isEditable()) {
501
+ const domRect: DOMRect | undefined = nativeSelection.focusNode?.parentElement?.getBoundingClientRect()
502
+ if (domRect) {
503
+ domRect.y += 40
504
+ setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem)
505
+ }
506
+ setLastSelection(selection)
507
+ } else if (!activeElement || !activeElement.classList.contains('link-input')) {
508
+ if (rootElement !== null) setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)
509
+ setLastSelection(null)
510
+ setIsLinkEditMode(false)
511
+ setLinkUrl('')
512
+ }
513
+ return true
514
+ }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl])
515
+
516
+ useEffect(() => {
517
+ const scrollerElem = anchorElem.parentElement
518
+ const update = () => editor.getEditorState().read(() => void $updateLinkEditor())
519
+ window.addEventListener('resize', update)
520
+ scrollerElem?.addEventListener('scroll', update)
521
+ return () => {
522
+ window.removeEventListener('resize', update)
523
+ scrollerElem?.removeEventListener('scroll', update)
524
+ }
525
+ }, [anchorElem.parentElement, editor, $updateLinkEditor])
526
+
527
+ useEffect(
528
+ () =>
529
+ mergeRegister(
530
+ editor.registerUpdateListener(({ editorState }) => editorState.read(() => void $updateLinkEditor())),
531
+ editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateLinkEditor(), true), COMMAND_PRIORITY_LOW),
532
+ editor.registerCommand(KEY_ESCAPE_COMMAND, () => (isLink ? (setIsLink(false), true) : false), COMMAND_PRIORITY_HIGH),
533
+ ),
534
+ [editor, $updateLinkEditor, setIsLink, isLink],
535
+ )
536
+
537
+ useEffect(() => {
538
+ editor.getEditorState().read(() => void $updateLinkEditor())
539
+ }, [editor, $updateLinkEditor])
540
+
541
+ useEffect(() => {
542
+ if (isLinkEditMode && inputRef.current) {
543
+ inputRef.current.focus()
544
+ setIsLink(true)
545
+ }
546
+ }, [isLinkEditMode, isLink])
547
+
548
+ useEffect(() => setEdited(false), [isLinkEditMode])
549
+ // Every address written passes `safeUrl`, the check the server makes of a write.
550
+ const allowed = safeUrl(editedLinkUrl) && editedLinkUrl.trim() !== newLink
551
+ const handleLinkSubmission = () => {
552
+ if (lastSelection === null || !allowed) return
553
+ if (linkUrl !== '') editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedLinkUrl.trim())
554
+ setEditedLinkUrl(newLink)
555
+ setIsLinkEditMode(false)
556
+ }
557
+ const cancel = () => {
558
+ if (linkUrl === newLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
559
+ setIsLinkEditMode(false)
560
+ setIsLink(false)
561
+ }
562
+ const address = t('admin.richText.linkAddress', 'Link address') ?? 'Link address'
563
+ const cancelling = t('admin.richText.cancelLink', 'Cancel') ?? 'Cancel'
564
+ const saving = t('admin.richText.saveLink', 'Save link') ?? 'Save link'
565
+ const editing = t('admin.richText.editLink', 'Edit link') ?? 'Edit link'
566
+ const removing = t('admin.richText.removeLink', 'Remove link') ?? 'Remove link'
567
+ return (
568
+ <div ref={editorRef} data-link-editor className="absolute top-0 left-0 z-20 w-full max-w-sm rounded-md opacity-0 shadow-md">
569
+ {!isLink ? null : isLinkEditMode ? (
570
+ <div className="bg-popover flex items-center gap-2 rounded-md border p-1 ps-2">
571
+ <Input
572
+ ref={inputRef}
573
+ dir="ltr"
574
+ type="url"
575
+ aria-label={address}
576
+ aria-invalid={edited && !allowed ? true : undefined}
577
+ value={editedLinkUrl}
578
+ onChange={(event) => (setEdited(true), setEditedLinkUrl(event.target.value))}
579
+ onBlur={() => setTimeout(() => {
580
+ if (editor.getRootElement()?.contains(document.activeElement) || editorRef.current?.contains(document.activeElement)) return
581
+ dropPlaceholders(editor, false)
582
+ setIsLinkEditMode(false)
583
+ })}
584
+ onKeyDown={(event) => {
585
+ if (event.key === 'Enter') {
586
+ event.preventDefault()
587
+ handleLinkSubmission()
588
+ } else if (event.key === 'Escape') {
589
+ event.preventDefault()
590
+ cancel()
591
+ }
592
+ }}
593
+ className="link-input flex-grow"
594
+ />
595
+ <Button type="button" size="icon" variant="ghost" title={cancelling} aria-label={cancelling} onClick={cancel} className="shrink-0">
596
+ <XIcon className="size-4" />
597
+ </Button>
598
+ <Button type="button" size="icon" disabled={!allowed} title={saving} aria-label={saving} onClick={handleLinkSubmission} className="shrink-0">
599
+ <CheckIcon className="size-4" />
600
+ </Button>
601
+ </div>
602
+ ) : (
603
+ <div className="bg-popover flex items-center justify-between gap-2 rounded-md border p-1 ps-2">
604
+ <a href={safeUrl(linkUrl) ? linkUrl : undefined} target="_blank" rel="noopener noreferrer" dir="ltr" className="overflow-hidden text-sm text-ellipsis whitespace-nowrap">
605
+ {linkUrl}
606
+ </a>
607
+ <div className="flex">
608
+ <Button type="button" size="icon" variant="ghost" title={editing} aria-label={editing} onClick={() => (setEditedLinkUrl(linkUrl), setIsLinkEditMode(true))}>
609
+ <PencilIcon className="size-4" />
610
+ </Button>
611
+ <Button type="button" size="icon" variant="destructive" title={removing} aria-label={removing} onClick={() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)}>
612
+ <TrashIcon className="size-4" />
613
+ </Button>
614
+ </div>
615
+ </div>
616
+ )}
617
+ </div>
618
+ )
619
+ }
620
+
621
+ /** Draws the link editor over the anchor while the selection sits in one link. */
622
+ function FloatingLinkEditorPlugin({ anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {
623
+ anchorElem: HTMLDivElement | null
624
+ isLinkEditMode: boolean
625
+ setIsLinkEditMode: (editing: boolean) => void
626
+ t: Translate
627
+ }): JSX.Element | null {
628
+ const [editor] = useLexicalComposerContext()
629
+ const [activeEditor, setActiveEditor] = useState(editor)
630
+ const [isLink, setIsLink] = useState(false)
631
+ useEffect(() => {
632
+ const $updateToolbar = () => {
633
+ const selection = $getSelection()
634
+ if ($isRangeSelection(selection)) {
635
+ const focusLinkNode = $findMatchingParent(getSelectedNode(selection), $isLinkNode)
636
+ if (!focusLinkNode) {
637
+ setIsLink(false)
638
+ return
639
+ }
640
+ const badNode = selection.getNodes().filter((node) => !$isLineBreakNode(node)).find((node) => {
641
+ const linkNode = $findMatchingParent(node, $isLinkNode)
642
+ return !focusLinkNode.is(linkNode) || (linkNode && !linkNode.is(focusLinkNode))
643
+ })
644
+ setIsLink(!badNode)
645
+ } else if ($isNodeSelection(selection)) {
646
+ const node = selection.getNodes()[0]
647
+ setIsLink(!!node && ($isLinkNode(node.getParent()) || $isLinkNode(node)))
648
+ }
649
+ }
650
+ // A placeholder is dropped once the link editor is no longer open on it, so a click elsewhere or a save never stores one.
651
+ const stale = (editorState: EditorState) =>
652
+ editorState.read(() => {
653
+ const selection = $getSelection()
654
+ const inside = $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null
655
+ return $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink && !node.is(inside))
656
+ })
657
+ return mergeRegister(
658
+ editor.registerUpdateListener(({ editorState }) => {
659
+ editorState.read(() => $updateToolbar())
660
+ if (stale(editorState)) dropPlaceholders(editor, true)
661
+ }),
662
+ editor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, newEditor) => ($updateToolbar(), setActiveEditor(newEditor), false), COMMAND_PRIORITY_CRITICAL),
663
+ editor.registerCommand(BLUR_COMMAND, () => (setTimeout(() => typingLink() || dropPlaceholders(editor, false)), false), COMMAND_PRIORITY_LOW),
664
+ )
665
+ }, [editor])
666
+ if (!anchorElem) return null
667
+ return createPortal(
668
+ <FloatingLinkEditor editor={activeEditor} isLink={isLink} anchorElem={anchorElem} setIsLink={setIsLink} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />,
669
+ anchorElem,
670
+ )
671
+ }
672
+
673
+ const headingPick = (tag: HeadingTagType, Icon: RichTextPick['Icon'], level: string): RichTextPick => ({
674
+ key: tag, label: { en: `Heading ${level}`, ar: `عنوان ${level}` }, Icon, keywords: ['heading', 'header', tag], block: true, onSelect: ({ editor }) => toHeading(editor, tag),
675
+ })
676
+ export const defaultPicks: readonly RichTextPick[] = [
677
+ { key: 'paragraph', label: { en: 'Paragraph', ar: 'فقرة' }, Icon: TextIcon, keywords: ['normal', 'paragraph', 'p', 'text'], block: true, onSelect: ({ editor }) => toParagraph(editor) },
678
+ headingPick('h1', Heading1Icon, '1'),
679
+ headingPick('h2', Heading2Icon, '2'),
680
+ headingPick('h3', Heading3Icon, '3'),
681
+ { key: 'number', label: { en: 'Numbered list', ar: 'قائمة مرقمة' }, Icon: ListOrderedIcon, keywords: ['numbered list', 'ordered list', 'ol'], block: true, onSelect: listOf(INSERT_ORDERED_LIST_COMMAND) },
682
+ { key: 'bullet', label: { en: 'Bulleted list', ar: 'قائمة نقطية' }, Icon: ListIcon, keywords: ['bulleted list', 'unordered list', 'ul'], block: true, onSelect: listOf(INSERT_UNORDERED_LIST_COMMAND) },
683
+ { key: 'check', label: { en: 'Check list', ar: 'قائمة مهام' }, Icon: ListTodoIcon, keywords: ['check list', 'todo list'], block: true, onSelect: listOf(INSERT_CHECK_LIST_COMMAND) },
684
+ {
685
+ key: 'quote',
686
+ label: { en: 'Quote', ar: 'اقتباس' },
687
+ Icon: QuoteIcon,
688
+ keywords: ['block quote'],
689
+ block: true,
690
+ onSelect: ({ editor, isActive }) => (isActive ? undefined : editor.update(() => $setBlocksType($getSelection(), () => $createQuoteNode()))),
691
+ },
692
+ { key: 'horizontalRule', label: { en: 'Divider', ar: 'خط فاصل' }, Icon: MinusIcon, keywords: ['horizontal rule', 'divider', 'hr'], onSelect: ({ editor }) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) },
693
+ ]
694
+
695
+ /** The React subclasses of nodes that draw React, each of the same type as the DOM-free class in the node list and extending it. */
696
+ export const defaultViews: readonly Klass<LexicalNode>[] = [HorizontalRuleView]
697
+
698
+ /** A node list as the browser draws it: each class swapped for its React subclass where one extends it. */
699
+ export const viewsOf = (nodes: readonly Klass<LexicalNode>[], views: readonly Klass<LexicalNode>[] = defaultViews) =>
700
+ nodes.map((node) => views.find((view) => view.prototype instanceof node) ?? node)
701
+
702
+ /**
703
+ * The nodes and picks every editor and viewer under it draws with, provided once by a screen from
704
+ * the project's own list so the editor, viewer and server read the same nodes. The registry's own where none is provided.
705
+ */
706
+ export const RichTextContext = createContext<{
707
+ nodes: readonly Klass<LexicalNode>[]
708
+ picks: readonly RichTextPick[]
709
+ /** What a project's own node mounts inside every editor beside its nodes, handed the field's control props (for example the upload node's dialog). */
710
+ Plugin?: ComponentType<ControlProps>
711
+ }>({ nodes: viewsOf(defaultNodes), picks: defaultPicks })
712
+
713
+ class PickerOption extends MenuOption {
714
+ item: RichTextPick
715
+ title: string
716
+ constructor(item: RichTextPick, title: string) {
717
+ super(item.key)
718
+ this.item = item
719
+ this.title = title
720
+ }
721
+ }
722
+
723
+ function PickerMenu({ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }: {
724
+ options: PickerOption[]
725
+ selectedIndex: number | null
726
+ selectOptionAndCleanUp: (option: PickerOption) => void
727
+ setHighlightedIndex: (index: number) => void
728
+ }) {
729
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
730
+ useEffect(() => {
731
+ if (selectedIndex !== null) itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', behavior: 'auto' })
732
+ }, [selectedIndex])
733
+ return (
734
+ <div className="border-border bg-popover text-popover-foreground h-min min-w-48 overflow-hidden rounded-lg border p-1 shadow-xl" role="listbox">
735
+ {options.map((option, index) => {
736
+ const { Icon } = option.item
737
+ return (
738
+ <button
739
+ key={option.key}
740
+ ref={(element) => {
741
+ itemRefs.current[index] = element
742
+ }}
743
+ type="button"
744
+ role="option"
745
+ aria-selected={selectedIndex === index}
746
+ className={cn('flex w-full items-center gap-2 rounded-md px-3 py-2 text-start text-sm outline-none', selectedIndex === index ? 'bg-accent' : 'hover:bg-muted')}
747
+ onClick={() => selectOptionAndCleanUp(option)}
748
+ onMouseDown={(event) => event.preventDefault()}
749
+ onMouseEnter={() => setHighlightedIndex(index)}
750
+ >
751
+ <span className="text-muted-foreground flex size-5 items-center justify-center"><Icon className="size-4" /></span>
752
+ <span className="flex-1">{option.title}</span>
753
+ </button>
754
+ )
755
+ })}
756
+ </div>
757
+ )
758
+ }
759
+
760
+ function SlashMenu({ locale, picks }: { locale: string; picks: readonly RichTextPick[] }): JSX.Element {
761
+ const [editor] = useLexicalComposerContext()
762
+ const [queryString, setQueryString] = useState<string | null>(null)
763
+ const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { minLength: 0 })
764
+ const options = useMemo(() => {
765
+ const all = picks.map((item) => new PickerOption(item, pickLabel(item, locale)))
766
+ if (!queryString) return all
767
+ // The typed word is compared as text, never as a pattern: a lone bracket would throw in a RegExp.
768
+ const word = queryString.toLowerCase()
769
+ return all.filter((option) => option.title.toLowerCase().includes(word) || (option.item.keywords ?? []).some((keyword) => keyword.includes(word)))
770
+ }, [queryString, locale, picks])
771
+ const onSelectOption = useCallback(
772
+ (selected: PickerOption, nodeToRemove: TextNode | null, closeMenu: () => void) => {
773
+ editor.update(() => {
774
+ nodeToRemove?.remove()
775
+ selected.item.onSelect({ editor, isActive: false })
776
+ closeMenu()
777
+ })
778
+ },
779
+ [editor],
780
+ )
781
+ return (
782
+ <LexicalTypeaheadMenuPlugin<PickerOption>
783
+ onQueryChange={setQueryString}
784
+ onSelectOption={onSelectOption}
785
+ triggerFn={checkForTriggerMatch}
786
+ options={options}
787
+ menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
788
+ if (!anchorElementRef.current || !options.length) return null
789
+ const anchorRect = anchorElementRef.current.getBoundingClientRect()
790
+ return createPortal(
791
+ <div className="fixed z-[1000]" style={{ left: anchorRect.left, top: anchorRect.bottom + 4 }}>
792
+ <PickerMenu options={options} selectedIndex={selectedIndex} selectOptionAndCleanUp={selectOptionAndCleanUp} setHighlightedIndex={setHighlightedIndex} />
793
+ </div>,
794
+ document.body,
795
+ )
796
+ }}
797
+ />
798
+ )
799
+ }
800
+
801
+ /**
802
+ * Clears the direction of the root and every block, so Lexical writes `dir="auto"` per block and
803
+ * each follows its first strong letter. Written as a history merge, so it is no change the form would save.
804
+ */
805
+ function DirectionPlugin() {
806
+ const [editor] = useLexicalComposerContext()
807
+ useEffect(() => {
808
+ editor.update(() => {
809
+ const root = $getRoot()
810
+ root.setDirection(null)
811
+ for (const block of root.getChildren()) if ($isElementNode(block) && block.getDirection() !== null) block.setDirection(null)
812
+ }, { tag: HISTORY_MERGE_TAG })
813
+ }, [editor])
814
+ return null
815
+ }
816
+
817
+ function ToolbarRow({ t, locale, picks, setLinkEditMode, direction }: { t: Translate; locale: string; picks: readonly RichTextPick[]; setLinkEditMode: (editing: boolean) => void; direction: RichTextDirection }) {
818
+ const [editor] = useLexicalComposerContext()
819
+ const bar = useRef<HTMLDivElement>(null)
820
+ const [blockType, setBlockType] = useState('paragraph')
821
+ const [activeEditor, setActiveEditor] = useState(editor)
822
+ useEffect(
823
+ () => activeEditor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, next) => (setActiveEditor(next), false), COMMAND_PRIORITY_CRITICAL),
824
+ [activeEditor],
825
+ )
826
+ // One tab stop for the whole toolbar: the button that last held focus, or the first enabled one, is tabbable. A button that changes state hands the stop on, so it never lands on a disabled one.
827
+ useEffect(() => {
828
+ const row = bar.current!
829
+ let last: Element | null = null
830
+ const enabled = () => [...row.querySelectorAll('button:not(:disabled)')] as HTMLElement[]
831
+ const sync = () => {
832
+ const all = [...row.querySelectorAll('button')] as HTMLElement[]
833
+ // A button disabled under focus (Undo, once nothing is left to undo) drops focus to the page: hand it to the nearest button that still takes it.
834
+ if (last instanceof HTMLButtonElement && last.disabled && (document.activeElement === last || document.activeElement === document.body)) {
835
+ const at = all.indexOf(last)
836
+ all.slice(at + 1).concat(all.slice(0, at).reverse()).find((one) => !(one as HTMLButtonElement).disabled)?.focus()
837
+ }
838
+ const keep = enabled().find((one) => one === last) ?? enabled()[0]
839
+ for (const one of all) one.tabIndex = one === keep ? 0 : -1
840
+ }
841
+ const focused = (event: FocusEvent) => ((last = event.target as Element), sync())
842
+ // Focus leaving the row forgets the button, so a later change cannot pull focus back in; a button disabled under focus is left to the hand-off above.
843
+ const left = (event: FocusEvent) => {
844
+ if (!row.contains(event.relatedTarget as Node | null) && !(event.target as HTMLButtonElement).disabled) last = null
845
+ }
846
+ sync()
847
+ const watch = new MutationObserver(sync)
848
+ watch.observe(row, { subtree: true, childList: true, attributeFilter: ['disabled'] })
849
+ row.addEventListener('focusin', focused)
850
+ row.addEventListener('focusout', left)
851
+ return () => (watch.disconnect(), row.removeEventListener('focusin', focused), row.removeEventListener('focusout', left))
852
+ }, [])
853
+ const move = (event: ReactKeyboardEvent<HTMLDivElement>) => {
854
+ // Alt+Left, Cmd+Arrow and Ctrl+Home are the browser's own, not the toolbar's.
855
+ if (event.altKey || event.ctrlKey || event.metaKey) return
856
+ const items = [...event.currentTarget.querySelectorAll('button:not(:disabled)')] as HTMLElement[]
857
+ const at = items.indexOf(document.activeElement as HTMLElement)
858
+ // A menu's own items sit in a portal, outside this row, so only a button of the row moves focus.
859
+ if (at < 0) return
860
+ const step = { ArrowRight: direction === 'rtl' ? -1 : 1, ArrowLeft: direction === 'rtl' ? 1 : -1 }[event.key]
861
+ const to = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : step === undefined ? undefined : (at + step + items.length) % items.length
862
+ if (to === undefined) return
863
+ event.preventDefault()
864
+ items[to]!.focus()
865
+ }
866
+ return (
867
+ // Sticky under the page's header, so no ancestor may scroll or clip (the editor box uses overflow-clip). The tint is opaque so scrolling text never shows through.
868
+ // On a phone it is one sideways-scrolling line, with contained width so it does not widen the page.
869
+ <div ref={bar} role="toolbar" aria-label={t('admin.richText.toolbar', 'Formatting')} aria-orientation="horizontal" onKeyDown={move} className="border-border bg-[color-mix(in_oklab,var(--muted)_20%,var(--background))] sticky top-[var(--header-bar-height,var(--header-height,0px))] z-9 flex flex-wrap items-center gap-1.5 border-b px-2 py-2 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:contain-inline-size max-sm:[&>*]:shrink-0">
870
+ <HistoryButtons activeEditor={activeEditor} t={t} />
871
+ <BlockDropdown toolbar={{ activeEditor, blockType, setBlockType }} t={t} locale={locale} picks={picks} />
872
+ <FormatButtons t={t} />
873
+ <LinkButton t={t} setLinkEditMode={setLinkEditMode} />
874
+ <ClearFormatting t={t} />
875
+ <AlignButtons t={t} />
876
+ </div>
877
+ )
878
+ }
879
+
880
+ const configOf = (namespace: string, nodes: readonly Klass<LexicalNode>[], value: RichTextValue, editable: boolean) => ({
881
+ namespace,
882
+ editable,
883
+ editorState: JSON.stringify(value),
884
+ nodes: [...nodes],
885
+ onError(error: Error) {
886
+ throw error
887
+ },
888
+ theme,
889
+ })
890
+
891
+ /**
892
+ * What stops these nodes drawing a stored value, or nothing: the first node type the value holds
893
+ * that no class of the list registers, or `''` where it nests past the server's bound or Lexical's own parse refuses it.
894
+ */
895
+ export const unreadable = (value: RichTextValue, nodes: readonly Klass<LexicalNode>[]): string | undefined => {
896
+ const type = unknownType({ node: value.root, types: typesOf(nodes) })
897
+ if (type !== undefined) return type
898
+ try {
899
+ createEditor({ nodes: [...nodes], onError: (error) => { throw error } }).parseEditorState(JSON.stringify(value))
900
+ return undefined
901
+ } catch {
902
+ return ''
903
+ }
904
+ }
905
+
906
+ /**
907
+ * One node of a value no editor here can mount, drawn as its words; a node no class registers is
908
+ * marked by its type. Stops at the server's depth bound with an ellipsis rather than overflowing the stack.
909
+ */
910
+ function Walked({ node, types, inline, depth = richTextBounds.depth }: { node: RichTextNode; types: ReadonlySet<string>; inline: boolean; depth?: number }): ReactNode {
911
+ if (depth <= 0) return '…'
912
+ if (!node || typeof node !== 'object') return null
913
+ if (node.type === 'linebreak') return <br />
914
+ if (typeof node.text === 'string' && types.has(node.type)) return node.text
915
+ const children = Array.isArray(node.children) ? node.children : []
916
+ const runs = children.some((child) => typeof child?.text === 'string')
917
+ const drawn = children.map((child, at) => <Walked key={at} node={child} types={types} inline={runs} depth={depth - 1} />)
918
+ if (!types.has(node.type)) {
919
+ return (
920
+ <span data-unknown-node={node.type} className="border-destructive text-destructive rounded border border-dashed px-1">
921
+ {node.type}{typeof node.text === 'string' ? `: ${node.text}` : null}{drawn.length ? <> {drawn}</> : null}
922
+ </span>
923
+ )
924
+ }
925
+ return inline ? <span>{drawn}</span> : <div className="mb-2">{drawn}</div>
926
+ }
927
+
928
+ /** A stored value this editor cannot mount, drawn read-only by `Walked`. It reports no change, so the field stays out of every save and nothing it holds is lost. */
929
+ function Unreadable({ value, nodes, problem, direction, t, id, describedBy }: {
930
+ value: RichTextValue
931
+ nodes: readonly Klass<LexicalNode>[]
932
+ problem: string
933
+ direction: RichTextDirection
934
+ t: Translate
935
+ id?: string
936
+ describedBy?: string
937
+ }): ReactElement {
938
+ const said = problem
939
+ ? (t('admin.richText.unknownNode', 'This text holds a "{type}" node this editor cannot draw, so it is shown as its words and cannot be changed here.') ?? '').replace('{type}', () => problem)
940
+ : (t('admin.richText.unreadable', 'This editor cannot read this text, so it is shown as its words and cannot be changed here.') ?? '')
941
+ return (
942
+ <div dir={direction} data-rich-text-view={id ?? ''} data-rich-text-unreadable={problem} className="grid gap-2">
943
+ <p role="alert" className="text-destructive text-sm">{said}</p>
944
+ <div id={id} aria-describedby={describedBy} className="text-sm leading-5">
945
+ {(Array.isArray(value.root.children) ? value.root.children : []).map((node, at) => <Walked key={at} node={node} types={typesOf(nodes)} inline={false} />)}
946
+ </div>
947
+ </div>
948
+ )
949
+ }
950
+
951
+ /**
952
+ * The editor, over the screen's nodes and picks (`RichTextContext`) unless handed its own.
953
+ * Uncontrolled past its first value, as Lexical is. A stored value it cannot mount is drawn by `Unreadable` and reports nothing.
954
+ */
955
+ export function RichTextEditor({ value, onChange, direction, t, locale, id, placeholder, describedBy, invalid, required, nodes, picks, control }: {
956
+ value: unknown
957
+ onChange: (value: RichTextValue) => void
958
+ direction: RichTextDirection
959
+ t: Translate
960
+ locale: string
961
+ id?: string
962
+ placeholder?: string
963
+ describedBy?: string
964
+ invalid?: boolean
965
+ required?: boolean
966
+ nodes?: readonly Klass<LexicalNode>[]
967
+ picks?: readonly RichTextPick[]
968
+ /** The field's control props, which the screen's `Plugin` is handed. */
969
+ control?: ControlProps
970
+ }): ReactElement {
971
+ const provided = use(RichTextContext)
972
+ const { Plugin } = provided
973
+ const drawn = nodes ?? provided.nodes
974
+ const [held] = useState(() => valueOf(value))
975
+ const [problem] = useState(() => unreadable(held, drawn))
976
+ // Built only over a value these nodes read, so a value nested past the bound never reaches `JSON.stringify` or Lexical.
977
+ const [initial] = useState(() => (problem === undefined ? configOf('MithaqRichText', drawn, held, true) : undefined))
978
+ const [isLinkEditMode, setIsLinkEditMode] = useState(false)
979
+ const [anchor, setAnchor] = useState<HTMLDivElement | null>(null)
980
+ if (problem !== undefined || !initial) return <Unreadable value={held} nodes={drawn} problem={problem ?? ''} direction={direction} t={t} id={id} describedBy={describedBy} />
981
+ return (
982
+ <div className="border-input bg-background overflow-clip rounded-lg border shadow-xs" dir={direction} data-rich-text={id}>
983
+ <LexicalComposer initialConfig={initial}>
984
+ <ToolbarRow t={t} locale={locale} picks={picks ?? provided.picks} setLinkEditMode={setIsLinkEditMode} direction={direction} />
985
+ <div className="relative" ref={setAnchor}>
986
+ <RichTextPlugin
987
+ contentEditable={
988
+ <ContentEditable
989
+ id={id}
990
+ // Named by the field's label, since a `for` cannot reach a div.
991
+ aria-labelledby={id ? `${id}-label` : undefined}
992
+ aria-describedby={describedBy}
993
+ aria-invalid={invalid ? true : undefined}
994
+ aria-required={required ? true : undefined}
995
+ aria-placeholder={placeholder ?? ''}
996
+ className="min-h-40 px-4 py-3 text-sm leading-6 outline-none"
997
+ placeholder={<div className="text-muted-foreground pointer-events-none absolute start-0 top-0 overflow-hidden px-4 py-3 text-sm text-ellipsis select-none">{placeholder ?? ''}</div>}
998
+ />
999
+ }
1000
+ ErrorBoundary={LexicalErrorBoundary}
1001
+ />
1002
+ </div>
1003
+ {/* A value holding an unconfirmed link is never handed on, so an autosave while the link editor is open sends no placeholder the server refuses. */}
1004
+ <OnChangePlugin ignoreSelectionChange onChange={(state) => holdsPlaceholder(state) || onChange(state.toJSON() as unknown as RichTextValue)} />
1005
+ <HistoryPlugin />
1006
+ <ListPlugin />
1007
+ <CheckListPlugin />
1008
+ <LinkPlugin validateUrl={safeUrl} />
1009
+ <FloatingLinkEditorPlugin anchorElem={anchor} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />
1010
+ <HorizontalRulePlugin />
1011
+ <SlashMenu locale={locale} picks={picks ?? provided.picks} />
1012
+ <DirectionPlugin />
1013
+ {Plugin && control ? <Plugin {...control} /> : null}
1014
+ </LexicalComposer>
1015
+ </div>
1016
+ )
1017
+ }
1018
+
1019
+ /**
1020
+ * The read view: the same nodes, never editable, remounted when the value changes. A link is drawn
1021
+ * through Lexical's `sanitizeUrl`, which answers `about:blank` for unsupported schemes, so a
1022
+ * `javascript:` or `data:` address that reached a column past the server's check is never followed.
1023
+ */
1024
+ export function RichTextViewer({ value, direction, t, className, id, describedBy, nodes }: {
1025
+ value: unknown
1026
+ direction: RichTextDirection
1027
+ t: Translate
1028
+ className?: string
1029
+ id?: string
1030
+ describedBy?: string
1031
+ nodes?: readonly Klass<LexicalNode>[]
1032
+ }): ReactElement {
1033
+ const drawn = nodes ?? use(RichTextContext).nodes
1034
+ const held = useMemo(() => valueOf(value), [value])
1035
+ // Asked before serializing, so a value nested past the bound never reaches `JSON.stringify` or Lexical.
1036
+ const problem = useMemo(() => unreadable(held, drawn), [held, drawn])
1037
+ if (problem !== undefined) return <Unreadable value={held} nodes={drawn} problem={problem} direction={direction} t={t} id={id} describedBy={describedBy} />
1038
+ const serialized = JSON.stringify(held)
1039
+ return (
1040
+ <div dir={direction} data-rich-text-view={id ?? ''}>
1041
+ <LexicalComposer key={serialized} initialConfig={configOf('MithaqRichTextViewer', drawn, held, false)}>
1042
+ <RichTextPlugin
1043
+ contentEditable={<ContentEditable id={id} aria-describedby={describedBy} className={cn('text-sm leading-5 outline-none [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_p:last-child]:mb-0', className)} />}
1044
+ ErrorBoundary={LexicalErrorBoundary}
1045
+ />
1046
+ <ListPlugin />
1047
+ <CheckListPlugin />
1048
+ <LinkPlugin validateUrl={safeUrl} />
1049
+ <DirectionPlugin />
1050
+ </LexicalComposer>
1051
+ </div>
1052
+ )
1053
+ }
1054
+
1055
+ /** The `richText` kind's control: the editor, or the viewer where the field is read only. An editor holding nothing but empty paragraphs reports `null`. */
1056
+ export function RichTextControl(props: ControlProps): ReactElement {
1057
+ const { describedBy, error, id, locale, placeholder, readOnly, t, value } = props
1058
+ const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'
1059
+ const change = changeOf(props)
1060
+ if (readOnly) return <RichTextViewer value={value} direction={direction} t={t} id={id} describedBy={describedBy} />
1061
+ return (
1062
+ <RichTextEditor
1063
+ value={value}
1064
+ onChange={(next) => change(blank(next) ? null : next)}
1065
+ direction={direction}
1066
+ t={t}
1067
+ locale={locale ?? 'en'}
1068
+ id={id}
1069
+ placeholder={placeholder}
1070
+ describedBy={describedBy}
1071
+ invalid={!!error}
1072
+ required={ariaRequired(props.field) === true}
1073
+ control={props}
1074
+ />
1075
+ )
1076
+ }
1077
+
1078
+ /** A rich text value as a record page reads it: the viewer, over the page's direction, and nothing where it holds none. */
1079
+ export function RichTextValueView({ value, t }: { value: unknown; t: Translate }): ReactNode {
1080
+ const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'
1081
+ return value ? <RichTextViewer value={value} direction={direction} t={t} /> : null
1082
+ }
1083
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 0709e5fd4e8b0eaeda128c63d9bb3d4bd885ef9f053b7374a40c0223e7aee874