dineway 0.1.3 → 0.1.5

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 (296) hide show
  1. package/README.md +6 -3
  2. package/dist/{apply-CAPvMfoU.mjs → apply-iVSqz2qs.mjs} +132 -39
  3. package/dist/astro/index.d.mts +18 -9
  4. package/dist/astro/index.mjs +238 -16
  5. package/dist/astro/middleware/auth.d.mts +16 -5
  6. package/dist/astro/middleware/auth.mjs +74 -37
  7. package/dist/astro/middleware/redirect.mjs +24 -8
  8. package/dist/astro/middleware/request-context.mjs +18 -5
  9. package/dist/astro/middleware/setup.mjs +1 -1
  10. package/dist/astro/middleware.mjs +411 -169
  11. package/dist/astro/types.d.mts +25 -8
  12. package/dist/{byline-DeWCMU_i.mjs → byline-OhH2dlRu.mjs} +6 -21
  13. package/dist/{bylines-DyqBV9EQ.mjs → bylines-BGpD9_hy.mjs} +16 -6
  14. package/dist/cache-BdSY-gQN.mjs +42 -0
  15. package/dist/chunks--4F8ddV4.mjs +18 -0
  16. package/dist/cli/index.mjs +935 -15
  17. package/dist/client/external-auth-headers.d.mts +1 -1
  18. package/dist/client/index.d.mts +11 -3
  19. package/dist/client/index.mjs +4 -3
  20. package/dist/{connection-C9pxzuag.mjs → connection-BCNICDWN.mjs} +22 -5
  21. package/dist/{content-zSgdNmnt.mjs → content-DWi4d0rT.mjs} +41 -2
  22. package/dist/database/instrumentation.d.mts +34 -0
  23. package/dist/database/instrumentation.mjs +53 -0
  24. package/dist/db/index.d.mts +3 -3
  25. package/dist/db/index.mjs +2 -2
  26. package/dist/db/libsql.d.mts +1 -1
  27. package/dist/db/libsql.mjs +11 -5
  28. package/dist/db/postgres.d.mts +1 -1
  29. package/dist/db/sqlite.d.mts +1 -1
  30. package/dist/db/sqlite.mjs +7 -1
  31. package/dist/db-errors-CEqD7qH9.mjs +23 -0
  32. package/dist/{default-WYlzADZL.mjs → default-VjJyuuG9.mjs} +2 -0
  33. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +3 -0
  34. package/dist/{error-DrxtnGPg.mjs → error-BmL6QipT.mjs} +7 -3
  35. package/dist/{index-C-jx21qs.d.mts → index-yvc6E_17.d.mts} +157 -30
  36. package/dist/index.d.mts +11 -11
  37. package/dist/index.mjs +24 -22
  38. package/dist/{loader-qKmo0wAY.mjs → loader-sMG4TZ-u.mjs} +9 -3
  39. package/dist/media/index.d.mts +1 -1
  40. package/dist/media/index.mjs +1 -1
  41. package/dist/media/local-runtime.d.mts +7 -7
  42. package/dist/page/index.d.mts +10 -2
  43. package/dist/page/index.mjs +22 -1
  44. package/dist/patterns-CrCYkMBb.mjs +92 -0
  45. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder--wOi4TbO.d.mts} +1 -1
  46. package/dist/{placeholder-B3knXwNc.mjs → placeholder-Cp8g5Emj.mjs} +1 -1
  47. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  48. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  49. package/dist/{query-BiaPl_g2.mjs → query-kDmwCsHh.mjs} +118 -50
  50. package/dist/{redirect-JPqLAbxa.mjs → redirect-DnEWAkVg.mjs} +43 -99
  51. package/dist/{registry-DSd1GWB8.mjs → registry-C0zjeB9P.mjs} +191 -123
  52. package/dist/request-cache-Dk5qPSOx.mjs +66 -0
  53. package/dist/request-context.d.mts +4 -16
  54. package/dist/{runner-B5l1JfOj.d.mts → runner-CFI6B6J2.d.mts} +1 -1
  55. package/dist/{runner-BGUGywgG.mjs → runner-DWZm2KQm.mjs} +589 -137
  56. package/dist/runtime.d.mts +6 -6
  57. package/dist/runtime.mjs +2 -2
  58. package/dist/{search-BNruJHDL.mjs → search-ByRGV2pq.mjs} +570 -424
  59. package/dist/seed/index.d.mts +2 -2
  60. package/dist/seed/index.mjs +11 -10
  61. package/dist/seo/index.d.mts +1 -1
  62. package/dist/storage/local.d.mts +1 -1
  63. package/dist/storage/local.mjs +1 -1
  64. package/dist/storage/s3.d.mts +11 -3
  65. package/dist/storage/s3.mjs +78 -15
  66. package/dist/taxonomies-1s5PaS_8.mjs +266 -0
  67. package/dist/transaction-Cn2rjY78.mjs +27 -0
  68. package/dist/{types-BgQeVaPj.d.mts → types-BuMDPy5C.d.mts} +52 -3
  69. package/dist/{types-DuNbGKjF.mjs → types-COeOq9nK.mjs} +6 -1
  70. package/dist/{types-ju-_ORz7.d.mts → types-CWbdtiux.d.mts} +13 -5
  71. package/dist/{types-D38djUXv.d.mts → types-Cj0KMIZV.d.mts} +16 -3
  72. package/dist/{types-DkvMXalq.d.mts → types-DOrVigru.d.mts} +159 -0
  73. package/dist/{validate-CXnRKfJK.mjs → validate-BZ5wnLLp.mjs} +2 -1
  74. package/dist/{validate-DVKJJ-M_.d.mts → validate-IPf8n4Fj.d.mts} +4 -51
  75. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +10 -10
  76. package/dist/version-BKXPsfmJ.mjs +6 -0
  77. package/package.json +53 -39
  78. package/src/astro/routes/PluginRegistry.tsx +21 -0
  79. package/src/astro/routes/admin.astro +99 -0
  80. package/src/astro/routes/api/admin/allowed-domains/[domain].ts +112 -0
  81. package/src/astro/routes/api/admin/allowed-domains/index.ts +108 -0
  82. package/src/astro/routes/api/admin/api-tokens/[id].ts +44 -0
  83. package/src/astro/routes/api/admin/api-tokens/index.ts +90 -0
  84. package/src/astro/routes/api/admin/briefing.ts +76 -0
  85. package/src/astro/routes/api/admin/bylines/[id]/index.ts +90 -0
  86. package/src/astro/routes/api/admin/bylines/index.ts +74 -0
  87. package/src/astro/routes/api/admin/comments/[id]/status.ts +120 -0
  88. package/src/astro/routes/api/admin/comments/[id].ts +64 -0
  89. package/src/astro/routes/api/admin/comments/bulk.ts +42 -0
  90. package/src/astro/routes/api/admin/comments/counts.ts +30 -0
  91. package/src/astro/routes/api/admin/comments/index.ts +46 -0
  92. package/src/astro/routes/api/admin/context/[id]/history.ts +35 -0
  93. package/src/astro/routes/api/admin/context/[id]/index.ts +35 -0
  94. package/src/astro/routes/api/admin/context/[id]/review.ts +57 -0
  95. package/src/astro/routes/api/admin/context/[id]/supersede.ts +58 -0
  96. package/src/astro/routes/api/admin/context/diff.ts +35 -0
  97. package/src/astro/routes/api/admin/context/index.ts +69 -0
  98. package/src/astro/routes/api/admin/context/stale.ts +35 -0
  99. package/src/astro/routes/api/admin/hitl-requests/[id]/index.ts +38 -0
  100. package/src/astro/routes/api/admin/hitl-requests/[id]/resolve.ts +54 -0
  101. package/src/astro/routes/api/admin/hitl-requests/index.ts +38 -0
  102. package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +132 -0
  103. package/src/astro/routes/api/admin/hooks/exclusive/index.ts +51 -0
  104. package/src/astro/routes/api/admin/oauth-clients/[id].ts +137 -0
  105. package/src/astro/routes/api/admin/oauth-clients/index.ts +95 -0
  106. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +91 -0
  107. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +91 -0
  108. package/src/astro/routes/api/admin/plugins/[id]/index.ts +38 -0
  109. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +98 -0
  110. package/src/astro/routes/api/admin/plugins/[id]/update.ts +154 -0
  111. package/src/astro/routes/api/admin/plugins/index.ts +32 -0
  112. package/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts +62 -0
  113. package/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts +33 -0
  114. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +135 -0
  115. package/src/astro/routes/api/admin/plugins/marketplace/index.ts +38 -0
  116. package/src/astro/routes/api/admin/plugins/updates.ts +28 -0
  117. package/src/astro/routes/api/admin/review-requests/[id]/index.ts +35 -0
  118. package/src/astro/routes/api/admin/review-requests/[id]/resolve.ts +52 -0
  119. package/src/astro/routes/api/admin/review-requests/index.ts +35 -0
  120. package/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts +33 -0
  121. package/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts +62 -0
  122. package/src/astro/routes/api/admin/themes/marketplace/index.ts +45 -0
  123. package/src/astro/routes/api/admin/users/[id]/disable.ts +72 -0
  124. package/src/astro/routes/api/admin/users/[id]/enable.ts +48 -0
  125. package/src/astro/routes/api/admin/users/[id]/index.ts +166 -0
  126. package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +72 -0
  127. package/src/astro/routes/api/admin/users/index.ts +66 -0
  128. package/src/astro/routes/api/auth/dev-bypass.ts +139 -0
  129. package/src/astro/routes/api/auth/invite/accept.ts +52 -0
  130. package/src/astro/routes/api/auth/invite/complete.ts +86 -0
  131. package/src/astro/routes/api/auth/invite/index.ts +99 -0
  132. package/src/astro/routes/api/auth/invite/register-options.ts +73 -0
  133. package/src/astro/routes/api/auth/logout.ts +40 -0
  134. package/src/astro/routes/api/auth/magic-link/send.ts +90 -0
  135. package/src/astro/routes/api/auth/magic-link/verify.ts +71 -0
  136. package/src/astro/routes/api/auth/me.ts +60 -0
  137. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +221 -0
  138. package/src/astro/routes/api/auth/oauth/[provider].ts +120 -0
  139. package/src/astro/routes/api/auth/passkey/[id].ts +124 -0
  140. package/src/astro/routes/api/auth/passkey/index.ts +54 -0
  141. package/src/astro/routes/api/auth/passkey/options.ts +85 -0
  142. package/src/astro/routes/api/auth/passkey/register/options.ts +88 -0
  143. package/src/astro/routes/api/auth/passkey/register/verify.ts +119 -0
  144. package/src/astro/routes/api/auth/passkey/verify.ts +72 -0
  145. package/src/astro/routes/api/auth/signup/complete.ts +87 -0
  146. package/src/astro/routes/api/auth/signup/request.ts +89 -0
  147. package/src/astro/routes/api/auth/signup/verify.ts +53 -0
  148. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +310 -0
  149. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +28 -0
  150. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +68 -0
  151. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +77 -0
  152. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +42 -0
  153. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +107 -0
  154. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +100 -0
  155. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +64 -0
  156. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +31 -0
  157. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +129 -0
  158. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +143 -0
  159. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +50 -0
  160. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +69 -0
  161. package/src/astro/routes/api/content/[collection]/[id].ts +173 -0
  162. package/src/astro/routes/api/content/[collection]/index.ts +103 -0
  163. package/src/astro/routes/api/content/[collection]/trash.ts +33 -0
  164. package/src/astro/routes/api/dashboard.ts +32 -0
  165. package/src/astro/routes/api/dev/emails.ts +36 -0
  166. package/src/astro/routes/api/health.ts +54 -0
  167. package/src/astro/routes/api/import/probe.ts +47 -0
  168. package/src/astro/routes/api/import/wordpress/analyze.ts +523 -0
  169. package/src/astro/routes/api/import/wordpress/execute.ts +330 -0
  170. package/src/astro/routes/api/import/wordpress/media.ts +338 -0
  171. package/src/astro/routes/api/import/wordpress/prepare.ts +212 -0
  172. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +425 -0
  173. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +111 -0
  174. package/src/astro/routes/api/import/wordpress-plugin/callback.ts +58 -0
  175. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +399 -0
  176. package/src/astro/routes/api/manifest.ts +75 -0
  177. package/src/astro/routes/api/mcp.ts +125 -0
  178. package/src/astro/routes/api/media/[id]/confirm.ts +93 -0
  179. package/src/astro/routes/api/media/[id].ts +145 -0
  180. package/src/astro/routes/api/media/file/[...key].ts +79 -0
  181. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +91 -0
  182. package/src/astro/routes/api/media/providers/[providerId]/index.ts +111 -0
  183. package/src/astro/routes/api/media/providers/index.ts +30 -0
  184. package/src/astro/routes/api/media/upload-url.ts +146 -0
  185. package/src/astro/routes/api/media.ts +204 -0
  186. package/src/astro/routes/api/menus/[name]/items.ts +206 -0
  187. package/src/astro/routes/api/menus/[name]/reorder.ts +79 -0
  188. package/src/astro/routes/api/menus/[name].ts +145 -0
  189. package/src/astro/routes/api/menus/index.ts +91 -0
  190. package/src/astro/routes/api/oauth/authorize.ts +430 -0
  191. package/src/astro/routes/api/oauth/device/authorize.ts +45 -0
  192. package/src/astro/routes/api/oauth/device/code.ts +56 -0
  193. package/src/astro/routes/api/oauth/device/token.ts +70 -0
  194. package/src/astro/routes/api/oauth/register.ts +182 -0
  195. package/src/astro/routes/api/oauth/token/refresh.ts +38 -0
  196. package/src/astro/routes/api/oauth/token/revoke.ts +38 -0
  197. package/src/astro/routes/api/oauth/token.ts +195 -0
  198. package/src/astro/routes/api/openapi.json.ts +33 -0
  199. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +109 -0
  200. package/src/astro/routes/api/redirects/404s/index.ts +72 -0
  201. package/src/astro/routes/api/redirects/404s/summary.ts +33 -0
  202. package/src/astro/routes/api/redirects/[id].ts +183 -0
  203. package/src/astro/routes/api/redirects/index.ts +100 -0
  204. package/src/astro/routes/api/revisions/[revisionId]/index.ts +29 -0
  205. package/src/astro/routes/api/revisions/[revisionId]/restore.ts +62 -0
  206. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +104 -0
  207. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +67 -0
  208. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +45 -0
  209. package/src/astro/routes/api/schema/collections/[slug]/index.ts +107 -0
  210. package/src/astro/routes/api/schema/collections/index.ts +61 -0
  211. package/src/astro/routes/api/schema/index.ts +109 -0
  212. package/src/astro/routes/api/schema/orphans/[slug].ts +36 -0
  213. package/src/astro/routes/api/schema/orphans/index.ts +26 -0
  214. package/src/astro/routes/api/search/enable.ts +64 -0
  215. package/src/astro/routes/api/search/index.ts +52 -0
  216. package/src/astro/routes/api/search/rebuild.ts +72 -0
  217. package/src/astro/routes/api/search/stats.ts +35 -0
  218. package/src/astro/routes/api/search/suggest.ts +50 -0
  219. package/src/astro/routes/api/sections/[slug].ts +203 -0
  220. package/src/astro/routes/api/sections/index.ts +107 -0
  221. package/src/astro/routes/api/settings/email.ts +150 -0
  222. package/src/astro/routes/api/settings.ts +116 -0
  223. package/src/astro/routes/api/setup/admin-verify.ts +122 -0
  224. package/src/astro/routes/api/setup/admin.ts +104 -0
  225. package/src/astro/routes/api/setup/dev-bypass.ts +200 -0
  226. package/src/astro/routes/api/setup/dev-reset.ts +40 -0
  227. package/src/astro/routes/api/setup/index.ts +128 -0
  228. package/src/astro/routes/api/setup/status.ts +122 -0
  229. package/src/astro/routes/api/snapshot.ts +76 -0
  230. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +232 -0
  231. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +131 -0
  232. package/src/astro/routes/api/taxonomies/index.ts +114 -0
  233. package/src/astro/routes/api/themes/preview.ts +78 -0
  234. package/src/astro/routes/api/typegen.ts +114 -0
  235. package/src/astro/routes/api/well-known/auth.ts +71 -0
  236. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +48 -0
  237. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +39 -0
  238. package/src/astro/routes/api/widget-areas/[name]/reorder.ts +114 -0
  239. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +213 -0
  240. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +126 -0
  241. package/src/astro/routes/api/widget-areas/[name].ts +135 -0
  242. package/src/astro/routes/api/widget-areas/index.ts +149 -0
  243. package/src/astro/routes/api/widget-components.ts +22 -0
  244. package/src/astro/routes/robots.txt.ts +81 -0
  245. package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
  246. package/src/astro/routes/sitemap.xml.ts +92 -0
  247. package/src/components/Break.astro +45 -0
  248. package/src/components/Button.astro +71 -0
  249. package/src/components/Buttons.astro +49 -0
  250. package/src/components/Code.astro +59 -0
  251. package/src/components/Columns.astro +59 -0
  252. package/src/components/CommentForm.astro +315 -0
  253. package/src/components/Comments.astro +232 -0
  254. package/src/components/Cover.astro +128 -0
  255. package/src/components/DinewayBodyEnd.astro +32 -0
  256. package/src/components/DinewayBodyStart.astro +32 -0
  257. package/src/components/DinewayHead.astro +61 -0
  258. package/src/components/DinewayImage.astro +178 -0
  259. package/src/components/DinewayMedia.astro +167 -0
  260. package/src/components/Embed.astro +128 -0
  261. package/src/components/File.astro +122 -0
  262. package/src/components/Gallery.astro +93 -0
  263. package/src/components/HtmlBlock.astro +33 -0
  264. package/src/components/Image.astro +178 -0
  265. package/src/components/InlineEditor.astro +27 -0
  266. package/src/components/InlinePortableTextEditor.tsx +1937 -0
  267. package/src/components/LiveSearch.astro +614 -0
  268. package/src/components/PortableText.astro +51 -0
  269. package/src/components/Pullquote.astro +51 -0
  270. package/src/components/Table.astro +135 -0
  271. package/src/components/WidgetArea.astro +22 -0
  272. package/src/components/WidgetRenderer.astro +72 -0
  273. package/src/components/index.ts +106 -0
  274. package/src/components/marks/Link.astro +31 -0
  275. package/src/components/marks/StrikeThrough.astro +7 -0
  276. package/src/components/marks/Subscript.astro +7 -0
  277. package/src/components/marks/Superscript.astro +7 -0
  278. package/src/components/marks/Underline.astro +7 -0
  279. package/src/components/marks.ts +19 -0
  280. package/src/components/widgets/Archives.astro +65 -0
  281. package/src/components/widgets/Categories.astro +35 -0
  282. package/src/components/widgets/RecentPosts.astro +51 -0
  283. package/src/components/widgets/Search.astro +18 -0
  284. package/src/components/widgets/Tags.astro +38 -0
  285. package/src/ui.ts +75 -0
  286. package/LICENSE +0 -9
  287. /package/dist/{adapters-BlzWJG82.d.mts → adapters-C2ypTrZZ.d.mts} +0 -0
  288. /package/dist/{config-Cq8H0SfX.mjs → config-BXwuX8Bx.mjs} +0 -0
  289. /package/dist/{load-C6FCD1FU.mjs → load-Coc9HpHH.mjs} +0 -0
  290. /package/dist/{manifest-schema-CTSEyIJ3.mjs → manifest-schema-D1MSVnoI.mjs} +0 -0
  291. /package/dist/{mode-BlyYtIFO.mjs → mode-47goXBBK.mjs} +0 -0
  292. /package/dist/{tokens-4vgYuXsZ.mjs → tokens-CJz9ubV6.mjs} +0 -0
  293. /package/dist/{transport-C5FYnid7.mjs → transport-DB5eDN4x.mjs} +0 -0
  294. /package/dist/{transport-gIL-e43D.d.mts → transport-Wge_IzKl.d.mts} +0 -0
  295. /package/dist/{types-CLLdsG3g.d.mts → types-BzcUjoqg.d.mts} +0 -0
  296. /package/dist/{types-DShnjzb6.mjs → types-griIBQOQ.mjs} +0 -0
@@ -0,0 +1,1937 @@
1
+ /**
2
+ * Self-contained inline Portable Text editor for visual editing.
3
+ *
4
+ * Uses TipTap directly with content extensions — no admin UI deps.
5
+ * Includes BubbleMenu for inline formatting (bold, italic, etc.)
6
+ * but no toolbar, no media picker, no section picker.
7
+ *
8
+ * Converts between Portable Text and ProseMirror on mount/save.
9
+ * Auto-saves on blur, dispatches custom events for toolbar integration.
10
+ */
11
+
12
+ import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/react";
13
+ import { Extension, type JSONContent, type Range } from "@tiptap/core";
14
+ import Focus from "@tiptap/extension-focus";
15
+ import Image from "@tiptap/extension-image";
16
+ import Link from "@tiptap/extension-link";
17
+ import Placeholder from "@tiptap/extension-placeholder";
18
+ import TextAlign from "@tiptap/extension-text-align";
19
+ import Typography from "@tiptap/extension-typography";
20
+ import Underline from "@tiptap/extension-underline";
21
+ import { useEditor, EditorContent, type Editor } from "@tiptap/react";
22
+ import { BubbleMenu } from "@tiptap/react/menus";
23
+ import StarterKit from "@tiptap/starter-kit";
24
+ import Suggestion from "@tiptap/suggestion";
25
+ import * as React from "react";
26
+ import { createPortal } from "react-dom";
27
+
28
+ import { computeThumbnailSize } from "../media/thumbnail.js";
29
+
30
+ // ── Portable Text types ────────────────────────────────────────────
31
+
32
+ interface PTSpan {
33
+ _type: "span";
34
+ _key: string;
35
+ text: string;
36
+ marks?: string[];
37
+ }
38
+
39
+ interface PTMarkDef {
40
+ _type: string;
41
+ _key: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ interface PTTextBlock {
46
+ _type: "block";
47
+ _key: string;
48
+ style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote";
49
+ listItem?: "bullet" | "number";
50
+ level?: number;
51
+ children: PTSpan[];
52
+ markDefs?: PTMarkDef[];
53
+ }
54
+
55
+ type PTBlock = PTTextBlock | { _type: string; _key: string; [key: string]: unknown };
56
+
57
+ /** Type guard for PTTextBlock */
58
+ function isPTTextBlock(block: PTBlock): block is PTTextBlock {
59
+ return block._type === "block";
60
+ }
61
+
62
+ /** Type guard for ProseMirror JSON document node */
63
+ function isPMNode(value: unknown): value is PMNode {
64
+ return (
65
+ typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
66
+ );
67
+ }
68
+
69
+ // ── Helpers ────────────────────────────────────────────────────────
70
+
71
+ function k(): string {
72
+ return Math.random().toString(36).substring(2, 11);
73
+ }
74
+
75
+ // ── ProseMirror → Portable Text ────────────────────────────────────
76
+
77
+ type PMNode = {
78
+ type: string;
79
+ attrs?: Record<string, unknown>;
80
+ content?: PMNode[];
81
+ marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
82
+ text?: string;
83
+ };
84
+
85
+ /** Safely extract a string attribute from ProseMirror attrs */
86
+ function attrStr(attrs: Record<string, unknown> | undefined, key: string): string {
87
+ const v = attrs?.[key];
88
+ return typeof v === "string" ? v : "";
89
+ }
90
+
91
+ /** Safely extract an optional string attribute from ProseMirror attrs */
92
+ function attrStrOpt(attrs: Record<string, unknown> | undefined, key: string): string | undefined {
93
+ const v = attrs?.[key];
94
+ return typeof v === "string" ? v : undefined;
95
+ }
96
+
97
+ /** Safely extract a number attribute from ProseMirror attrs */
98
+ function attrNum(attrs: Record<string, unknown> | undefined, key: string): number | undefined {
99
+ const v = attrs?.[key];
100
+ return typeof v === "number" ? v : undefined;
101
+ }
102
+
103
+ function pmToPortableText(doc: PMNode): PTBlock[] {
104
+ if (!doc || doc.type !== "doc" || !doc.content) return [];
105
+ const blocks: PTBlock[] = [];
106
+ for (const node of doc.content) {
107
+ const r = convertPMNode(node);
108
+ if (r) {
109
+ if (Array.isArray(r)) blocks.push(...r);
110
+ else blocks.push(r);
111
+ }
112
+ }
113
+ return blocks;
114
+ }
115
+
116
+ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null {
117
+ switch (node.type) {
118
+ case "paragraph": {
119
+ const { children, markDefs } = convertInline(node.content || []);
120
+ if (children.length === 0) return null;
121
+ return {
122
+ _type: "block",
123
+ _key: k(),
124
+ style: "normal",
125
+ children,
126
+ markDefs: markDefs.length > 0 ? markDefs : undefined,
127
+ };
128
+ }
129
+ case "heading": {
130
+ const { children, markDefs } = convertInline(node.content || []);
131
+ const level = attrNum(node.attrs, "level") ?? 1;
132
+ if (children.length === 0) return null;
133
+ const headingStyles: Record<number, PTTextBlock["style"]> = {
134
+ 1: "h1",
135
+ 2: "h2",
136
+ 3: "h3",
137
+ 4: "h4",
138
+ 5: "h5",
139
+ 6: "h6",
140
+ };
141
+ const headingStyle = headingStyles[level] ?? "h1";
142
+ return {
143
+ _type: "block",
144
+ _key: k(),
145
+ style: headingStyle,
146
+ children,
147
+ markDefs: markDefs.length > 0 ? markDefs : undefined,
148
+ };
149
+ }
150
+ case "bulletList":
151
+ return convertPMList(node.content || [], "bullet");
152
+ case "orderedList":
153
+ return convertPMList(node.content || [], "number");
154
+ case "blockquote": {
155
+ const blocks: PTTextBlock[] = [];
156
+ for (const child of node.content || []) {
157
+ if (child.type === "paragraph") {
158
+ const { children, markDefs } = convertInline(child.content || []);
159
+ if (children.length > 0) {
160
+ blocks.push({
161
+ _type: "block",
162
+ _key: k(),
163
+ style: "blockquote",
164
+ children,
165
+ markDefs: markDefs.length > 0 ? markDefs : undefined,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ if (blocks.length === 1) {
171
+ const first = blocks[0];
172
+ return first ?? null;
173
+ }
174
+ return blocks.length > 0 ? blocks : null;
175
+ }
176
+ case "codeBlock": {
177
+ const code = (node.content || []).map((n) => n.text || "").join("");
178
+ return {
179
+ _type: "code",
180
+ _key: k(),
181
+ code,
182
+ language: attrStrOpt(node.attrs, "language"),
183
+ };
184
+ }
185
+ case "image": {
186
+ const provider = attrStrOpt(node.attrs, "provider");
187
+ return {
188
+ _type: "image",
189
+ _key: k(),
190
+ asset: {
191
+ _ref: attrStr(node.attrs, "mediaId"),
192
+ url: attrStr(node.attrs, "src"),
193
+ provider: provider && provider !== "local" ? provider : undefined,
194
+ },
195
+ alt: attrStrOpt(node.attrs, "alt"),
196
+ caption: attrStrOpt(node.attrs, "caption") ?? attrStrOpt(node.attrs, "title"),
197
+ width: attrNum(node.attrs, "width"),
198
+ height: attrNum(node.attrs, "height"),
199
+ displayWidth: attrNum(node.attrs, "displayWidth"),
200
+ displayHeight: attrNum(node.attrs, "displayHeight"),
201
+ };
202
+ }
203
+ case "horizontalRule":
204
+ return { _type: "break", _key: k(), style: "lineBreak" };
205
+ case "pluginBlock":
206
+ return {
207
+ _type: attrStr(node.attrs, "blockType") || "embed",
208
+ _key: k(),
209
+ id: attrStr(node.attrs, "id"),
210
+ };
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function convertPMList(items: PMNode[], listItem: "bullet" | "number"): PTTextBlock[] {
217
+ const blocks: PTTextBlock[] = [];
218
+ for (const item of items) {
219
+ if (item.type === "listItem") {
220
+ for (const child of item.content || []) {
221
+ if (child.type === "paragraph") {
222
+ const { children, markDefs } = convertInline(child.content || []);
223
+ if (children.length > 0) {
224
+ blocks.push({
225
+ _type: "block",
226
+ _key: k(),
227
+ style: "normal",
228
+ listItem,
229
+ level: 1,
230
+ children,
231
+ markDefs: markDefs.length > 0 ? markDefs : undefined,
232
+ });
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ return blocks;
239
+ }
240
+
241
+ function convertInline(nodes: PMNode[]): { children: PTSpan[]; markDefs: PTMarkDef[] } {
242
+ const children: PTSpan[] = [];
243
+ const markDefs: PTMarkDef[] = [];
244
+ const markDefMap = new Map<string, string>();
245
+
246
+ for (const node of nodes) {
247
+ if (node.type === "text" && node.text) {
248
+ const marks: string[] = [];
249
+ for (const mark of node.marks || []) {
250
+ const m = convertPMMark(mark, markDefs, markDefMap);
251
+ if (m) marks.push(m);
252
+ }
253
+ children.push({
254
+ _type: "span",
255
+ _key: k(),
256
+ text: node.text,
257
+ marks: marks.length > 0 ? marks : undefined,
258
+ });
259
+ } else if (node.type === "hardBreak") {
260
+ if (children.length > 0) {
261
+ const last = children.at(-1);
262
+ if (last) last.text += "\n";
263
+ } else {
264
+ children.push({ _type: "span", _key: k(), text: "\n" });
265
+ }
266
+ }
267
+ }
268
+
269
+ if (children.length === 0) {
270
+ children.push({ _type: "span", _key: k(), text: "" });
271
+ }
272
+ return { children, markDefs };
273
+ }
274
+
275
+ function convertPMMark(
276
+ mark: { type: string; attrs?: Record<string, unknown> },
277
+ markDefs: PTMarkDef[],
278
+ markDefMap: Map<string, string>,
279
+ ): string | null {
280
+ switch (mark.type) {
281
+ case "bold":
282
+ case "strong":
283
+ return "strong";
284
+ case "italic":
285
+ case "em":
286
+ return "em";
287
+ case "underline":
288
+ return "underline";
289
+ case "strike":
290
+ case "strikethrough":
291
+ return "strike-through";
292
+ case "code":
293
+ return "code";
294
+ case "link": {
295
+ const href = attrStr(mark.attrs, "href");
296
+ if (markDefMap.has(href)) return markDefMap.get(href)!;
297
+ const key = k();
298
+ markDefs.push({
299
+ _type: "link",
300
+ _key: key,
301
+ href,
302
+ blank: mark.attrs?.target === "_blank",
303
+ });
304
+ markDefMap.set(href, key);
305
+ return key;
306
+ }
307
+ default:
308
+ return mark.type;
309
+ }
310
+ }
311
+
312
+ // ── Portable Text → ProseMirror ────────────────────────────────────
313
+
314
+ function portableTextToPM(blocks: PTBlock[]): JSONContent {
315
+ if (!blocks || blocks.length === 0) return { type: "doc", content: [{ type: "paragraph" }] };
316
+
317
+ const content: JSONContent[] = [];
318
+ let i = 0;
319
+
320
+ while (i < blocks.length) {
321
+ const block = blocks[i];
322
+ if (!block) {
323
+ i++;
324
+ continue;
325
+ }
326
+ if (isPTTextBlock(block) && block.listItem) {
327
+ const listBlocks: PTTextBlock[] = [];
328
+ const listType = block.listItem;
329
+ while (i < blocks.length) {
330
+ const cur = blocks[i];
331
+ if (cur && isPTTextBlock(cur) && cur.listItem === listType) {
332
+ listBlocks.push(cur);
333
+ i++;
334
+ } else break;
335
+ }
336
+ content.push(convertPTList(listBlocks, listType));
337
+ } else {
338
+ const c = convertPTBlock(block);
339
+ if (c) content.push(c);
340
+ i++;
341
+ }
342
+ }
343
+
344
+ return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }] };
345
+ }
346
+
347
+ function convertPTBlock(block: PTBlock): JSONContent | null {
348
+ if (isPTTextBlock(block)) {
349
+ const { style = "normal", children, markDefs = [] } = block;
350
+ const pmContent = convertPTSpans(children, markDefs);
351
+
352
+ if (style === "blockquote") {
353
+ return {
354
+ type: "blockquote",
355
+ content: [
356
+ {
357
+ type: "paragraph",
358
+ content: pmContent.length > 0 ? pmContent : undefined,
359
+ },
360
+ ],
361
+ };
362
+ }
363
+ if (style?.startsWith("h")) {
364
+ const level = parseInt(style.substring(1), 10);
365
+ return {
366
+ type: "heading",
367
+ attrs: { level },
368
+ content: pmContent.length > 0 ? pmContent : undefined,
369
+ };
370
+ }
371
+ return {
372
+ type: "paragraph",
373
+ content: pmContent.length > 0 ? pmContent : undefined,
374
+ };
375
+ }
376
+ if (block._type === "code") {
377
+ const cb = block as PTBlock & { code?: string; language?: string };
378
+ return {
379
+ type: "codeBlock",
380
+ attrs: { language: cb.language || null },
381
+ content: cb.code ? [{ type: "text", text: cb.code }] : undefined,
382
+ };
383
+ }
384
+ if (block._type === "break") {
385
+ return { type: "horizontalRule" };
386
+ }
387
+ if (block._type === "image") {
388
+ const ib = block as PTBlock & {
389
+ asset?: { _ref?: string; url?: string; provider?: string };
390
+ url?: string;
391
+ alt?: string;
392
+ caption?: string;
393
+ width?: number;
394
+ height?: number;
395
+ displayWidth?: number;
396
+ displayHeight?: number;
397
+ };
398
+ const asset = ib.asset;
399
+ return {
400
+ type: "image",
401
+ attrs: {
402
+ src: asset?.url || ib.url || (asset?._ref ? `/_dineway/api/media/file/${asset._ref}` : ""),
403
+ alt: ib.alt || "",
404
+ title: ib.caption || "",
405
+ caption: ib.caption || "",
406
+ mediaId: asset?._ref,
407
+ provider: asset?.provider,
408
+ width: ib.width,
409
+ height: ib.height,
410
+ displayWidth: ib.displayWidth,
411
+ displayHeight: ib.displayHeight,
412
+ },
413
+ };
414
+ }
415
+ // Unknown block types — treat as plugin blocks if they have an id
416
+ const embedBlock = block as { _type: string; url?: string; id?: string };
417
+ if (embedBlock.id || embedBlock.url) {
418
+ return {
419
+ type: "pluginBlock",
420
+ attrs: {
421
+ blockType: block._type,
422
+ id: embedBlock.id || embedBlock.url || "",
423
+ },
424
+ };
425
+ }
426
+ // Truly unknown — render as code-marked text
427
+ return {
428
+ type: "paragraph",
429
+ content: [{ type: "text", text: `[${block._type}]`, marks: [{ type: "code" }] }],
430
+ };
431
+ }
432
+
433
+ function convertPTList(items: PTTextBlock[], listType: "bullet" | "number"): JSONContent {
434
+ return {
435
+ type: listType === "bullet" ? "bulletList" : "orderedList",
436
+ content: items.map((item) => ({
437
+ type: "listItem",
438
+ content: [
439
+ {
440
+ type: "paragraph",
441
+ content: convertPTSpans(item.children, item.markDefs || []),
442
+ },
443
+ ],
444
+ })),
445
+ };
446
+ }
447
+
448
+ function convertPTSpans(spans: PTSpan[], markDefs: PTMarkDef[]): JSONContent[] {
449
+ const nodes: JSONContent[] = [];
450
+ const mdMap = new Map(markDefs.map((md) => [md._key, md]));
451
+
452
+ for (const span of spans) {
453
+ if (span._type !== "span") continue;
454
+ const parts = span.text.split("\n");
455
+ for (let i = 0; i < parts.length; i++) {
456
+ const text = parts[i];
457
+ if (text && text.length > 0) {
458
+ const marks = convertPTMarks(span.marks || [], mdMap);
459
+ const node: JSONContent = {
460
+ type: "text",
461
+ text,
462
+ };
463
+ if (marks.length > 0) node.marks = marks;
464
+ nodes.push(node);
465
+ }
466
+ if (i < parts.length - 1) nodes.push({ type: "hardBreak" });
467
+ }
468
+ }
469
+ return nodes;
470
+ }
471
+
472
+ type MarkJSON = { type: string; attrs?: Record<string, unknown>; [key: string]: unknown };
473
+
474
+ function convertPTMarks(marks: string[], markDefs: Map<string, PTMarkDef>): MarkJSON[] {
475
+ const pm: MarkJSON[] = [];
476
+ for (const mark of marks) {
477
+ switch (mark) {
478
+ case "strong":
479
+ pm.push({ type: "bold" });
480
+ break;
481
+ case "em":
482
+ pm.push({ type: "italic" });
483
+ break;
484
+ case "underline":
485
+ pm.push({ type: "underline" });
486
+ break;
487
+ case "strike-through":
488
+ pm.push({ type: "strike" });
489
+ break;
490
+ case "code":
491
+ pm.push({ type: "code" });
492
+ break;
493
+ default: {
494
+ const md = markDefs.get(mark);
495
+ if (md && md._type === "link") {
496
+ pm.push({
497
+ type: "link",
498
+ attrs: { href: md.href, target: md.blank ? "_blank" : null },
499
+ });
500
+ }
501
+ break;
502
+ }
503
+ }
504
+ }
505
+ return pm;
506
+ }
507
+
508
+ // ── Inline BubbleMenu ──────────────────────────────────────────────
509
+
510
+ function InlineBubbleMenu({ editor }: { editor: Editor }) {
511
+ const [showLinkInput, setShowLinkInput] = React.useState(false);
512
+ const [linkUrl, setLinkUrl] = React.useState("");
513
+ const inputRef = React.useRef<HTMLInputElement>(null);
514
+
515
+ React.useEffect(() => {
516
+ if (showLinkInput) {
517
+ const existingUrl = editor.getAttributes("link").href || "";
518
+ setLinkUrl(existingUrl);
519
+ setTimeout(() => inputRef.current?.focus(), 0);
520
+ }
521
+ }, [showLinkInput, editor]);
522
+
523
+ const handleSetLink = () => {
524
+ if (linkUrl.trim() === "") {
525
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
526
+ } else {
527
+ editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run();
528
+ }
529
+ setShowLinkInput(false);
530
+ setLinkUrl("");
531
+ };
532
+
533
+ const handleRemoveLink = () => {
534
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
535
+ setShowLinkInput(false);
536
+ setLinkUrl("");
537
+ };
538
+
539
+ const handleKeyDown = (e: React.KeyboardEvent) => {
540
+ if (e.key === "Enter") {
541
+ e.preventDefault();
542
+ handleSetLink();
543
+ } else if (e.key === "Escape") {
544
+ setShowLinkInput(false);
545
+ setLinkUrl("");
546
+ editor.commands.focus();
547
+ }
548
+ };
549
+
550
+ return (
551
+ <BubbleMenu
552
+ editor={editor}
553
+ options={{ placement: "top", offset: 8, flip: true, shift: true }}
554
+ className="dineway-bubble-menu"
555
+ >
556
+ {showLinkInput ? (
557
+ <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
558
+ <input
559
+ ref={inputRef}
560
+ type="url"
561
+ placeholder="https://..."
562
+ value={linkUrl}
563
+ onChange={(e) => setLinkUrl(e.target.value)}
564
+ onKeyDown={handleKeyDown}
565
+ className="dineway-bubble-link-input"
566
+ />
567
+ <button
568
+ type="button"
569
+ className="dineway-bubble-btn"
570
+ onClick={handleSetLink}
571
+ title="Apply link"
572
+ >
573
+
574
+ </button>
575
+ {editor.isActive("link") && (
576
+ <button
577
+ type="button"
578
+ className="dineway-bubble-btn dineway-bubble-btn--danger"
579
+ onClick={handleRemoveLink}
580
+ title="Remove link"
581
+ >
582
+
583
+ </button>
584
+ )}
585
+ </div>
586
+ ) : (
587
+ <>
588
+ <button
589
+ type="button"
590
+ className={`dineway-bubble-btn ${editor.isActive("bold") ? "dineway-bubble-btn--active" : ""}`}
591
+ onClick={() => editor.chain().focus().toggleBold().run()}
592
+ title="Bold"
593
+ >
594
+ <strong>B</strong>
595
+ </button>
596
+ <button
597
+ type="button"
598
+ className={`dineway-bubble-btn ${editor.isActive("italic") ? "dineway-bubble-btn--active" : ""}`}
599
+ onClick={() => editor.chain().focus().toggleItalic().run()}
600
+ title="Italic"
601
+ >
602
+ <em>I</em>
603
+ </button>
604
+ <button
605
+ type="button"
606
+ className={`dineway-bubble-btn ${editor.isActive("underline") ? "dineway-bubble-btn--active" : ""}`}
607
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
608
+ title="Underline"
609
+ >
610
+ <span style={{ textDecoration: "underline" }}>U</span>
611
+ </button>
612
+ <button
613
+ type="button"
614
+ className={`dineway-bubble-btn ${editor.isActive("strike") ? "dineway-bubble-btn--active" : ""}`}
615
+ onClick={() => editor.chain().focus().toggleStrike().run()}
616
+ title="Strikethrough"
617
+ >
618
+ <span style={{ textDecoration: "line-through" }}>S</span>
619
+ </button>
620
+ <button
621
+ type="button"
622
+ className={`dineway-bubble-btn ${editor.isActive("code") ? "dineway-bubble-btn--active" : ""}`}
623
+ onClick={() => editor.chain().focus().toggleCode().run()}
624
+ title="Code"
625
+ >
626
+ <span style={{ fontFamily: "monospace", fontSize: "13px" }}>&lt;/&gt;</span>
627
+ </button>
628
+ <span className="dineway-bubble-divider" />
629
+ <button
630
+ type="button"
631
+ className={`dineway-bubble-btn ${editor.isActive("link") ? "dineway-bubble-btn--active" : ""}`}
632
+ onClick={() => setShowLinkInput(true)}
633
+ title={editor.isActive("link") ? "Edit link" : "Add link"}
634
+ >
635
+ 🔗
636
+ </button>
637
+ </>
638
+ )}
639
+ </BubbleMenu>
640
+ );
641
+ }
642
+
643
+ // ── Slash Menu ──────────────────────────────────────────────────────
644
+
645
+ interface SlashCommandItem {
646
+ id: string;
647
+ title: string;
648
+ description: string;
649
+ icon: string;
650
+ command: (props: { editor: Editor; range: Range }) => void;
651
+ aliases?: string[];
652
+ }
653
+
654
+ const slashCommands: SlashCommandItem[] = [
655
+ {
656
+ id: "heading1",
657
+ title: "Heading 1",
658
+ description: "Large section heading",
659
+ icon: "H1",
660
+ aliases: ["h1", "title"],
661
+ command: ({ editor, range }) => {
662
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
663
+ },
664
+ },
665
+ {
666
+ id: "heading2",
667
+ title: "Heading 2",
668
+ description: "Medium section heading",
669
+ icon: "H2",
670
+ aliases: ["h2", "subtitle"],
671
+ command: ({ editor, range }) => {
672
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
673
+ },
674
+ },
675
+ {
676
+ id: "heading3",
677
+ title: "Heading 3",
678
+ description: "Small section heading",
679
+ icon: "H3",
680
+ aliases: ["h3"],
681
+ command: ({ editor, range }) => {
682
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
683
+ },
684
+ },
685
+ {
686
+ id: "bulletList",
687
+ title: "Bullet List",
688
+ description: "Create a bullet list",
689
+ icon: "•",
690
+ aliases: ["ul", "unordered"],
691
+ command: ({ editor, range }) => {
692
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
693
+ },
694
+ },
695
+ {
696
+ id: "numberedList",
697
+ title: "Numbered List",
698
+ description: "Create a numbered list",
699
+ icon: "1.",
700
+ aliases: ["ol", "ordered"],
701
+ command: ({ editor, range }) => {
702
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
703
+ },
704
+ },
705
+ {
706
+ id: "quote",
707
+ title: "Quote",
708
+ description: "Insert a blockquote",
709
+ icon: "\u201C",
710
+ aliases: ["blockquote", "cite"],
711
+ command: ({ editor, range }) => {
712
+ editor.chain().focus().deleteRange(range).toggleBlockquote().run();
713
+ },
714
+ },
715
+ {
716
+ id: "codeBlock",
717
+ title: "Code Block",
718
+ description: "Insert a code block",
719
+ icon: "</>",
720
+ aliases: ["code", "pre", "```"],
721
+ command: ({ editor, range }) => {
722
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
723
+ },
724
+ },
725
+ {
726
+ id: "divider",
727
+ title: "Divider",
728
+ description: "Insert a horizontal rule",
729
+ icon: "—",
730
+ aliases: ["hr", "---", "separator"],
731
+ command: ({ editor, range }) => {
732
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
733
+ },
734
+ },
735
+ {
736
+ id: "image",
737
+ title: "Image",
738
+ description: "Insert an image",
739
+ icon: "🖼",
740
+ aliases: ["img", "photo", "picture"],
741
+ command: ({ editor, range }) => {
742
+ editor.chain().focus().deleteRange(range).run();
743
+ // Signal the component to open the media picker
744
+ document.dispatchEvent(new CustomEvent("dineway:open-media-picker"));
745
+ },
746
+ },
747
+ ];
748
+
749
+ interface SlashMenuState {
750
+ isOpen: boolean;
751
+ items: SlashCommandItem[];
752
+ selectedIndex: number;
753
+ clientRect: (() => DOMRect | null) | null;
754
+ range: Range | null;
755
+ }
756
+
757
+ const initialSlashMenuState: SlashMenuState = {
758
+ isOpen: false,
759
+ items: [],
760
+ selectedIndex: 0,
761
+ clientRect: null,
762
+ range: null,
763
+ };
764
+
765
+ function createSlashCommandsExtension(options: {
766
+ filterCommands: (query: string) => SlashCommandItem[];
767
+ onStateChange: React.Dispatch<React.SetStateAction<SlashMenuState>>;
768
+ getState: () => SlashMenuState;
769
+ }) {
770
+ const { filterCommands, onStateChange, getState } = options;
771
+
772
+ return Extension.create({
773
+ name: "slashCommands",
774
+
775
+ addProseMirrorPlugins() {
776
+ return [
777
+ Suggestion({
778
+ editor: this.editor,
779
+ char: "/",
780
+ startOfLine: true,
781
+ command: ({ editor, range, props }) => {
782
+ const item: unknown = props;
783
+ if (
784
+ typeof item === "object" &&
785
+ item !== null &&
786
+ "command" in item &&
787
+ typeof item.command === "function"
788
+ ) {
789
+ item.command({ editor, range });
790
+ }
791
+ },
792
+ items: ({ query }) => filterCommands(query),
793
+ render: () => {
794
+ return {
795
+ onStart: (props) => {
796
+ onStateChange({
797
+ isOpen: true,
798
+ items: props.items,
799
+ selectedIndex: 0,
800
+ clientRect: props.clientRect ?? null,
801
+ range: props.range,
802
+ });
803
+ },
804
+ onUpdate: (props) => {
805
+ onStateChange((prev) => ({
806
+ ...prev,
807
+ items: props.items,
808
+ selectedIndex: 0,
809
+ clientRect: props.clientRect ?? null,
810
+ range: props.range,
811
+ }));
812
+ },
813
+ onKeyDown: (props) => {
814
+ if (props.event.key === "Escape") {
815
+ onStateChange((prev) => ({ ...prev, isOpen: false }));
816
+ return true;
817
+ }
818
+ if (props.event.key === "ArrowUp") {
819
+ onStateChange((prev) => ({
820
+ ...prev,
821
+ selectedIndex: (prev.selectedIndex - 1 + prev.items.length) % prev.items.length,
822
+ }));
823
+ return true;
824
+ }
825
+ if (props.event.key === "ArrowDown") {
826
+ onStateChange((prev) => ({
827
+ ...prev,
828
+ selectedIndex: (prev.selectedIndex + 1) % prev.items.length,
829
+ }));
830
+ return true;
831
+ }
832
+ if (props.event.key === "Enter") {
833
+ const state = getState();
834
+ if (state.items.length > 0 && state.range) {
835
+ const item = state.items[state.selectedIndex];
836
+ if (item) {
837
+ item.command({ editor: this.editor, range: state.range });
838
+ onStateChange((prev) => ({ ...prev, isOpen: false }));
839
+ return true;
840
+ }
841
+ }
842
+ return false;
843
+ }
844
+ return false;
845
+ },
846
+ onExit: () => {
847
+ onStateChange((prev) => ({ ...prev, isOpen: false }));
848
+ },
849
+ };
850
+ },
851
+ }),
852
+ ];
853
+ },
854
+ });
855
+ }
856
+
857
+ function InlineSlashMenu({
858
+ state,
859
+ onCommand,
860
+ setSelectedIndex,
861
+ }: {
862
+ state: SlashMenuState;
863
+ onCommand: (item: SlashCommandItem) => void;
864
+ setSelectedIndex: (index: number) => void;
865
+ }) {
866
+ const containerRef = React.useRef<HTMLDivElement>(null);
867
+
868
+ // Track whether we have a positioned reference to avoid rendering at (0,0)
869
+ const [hasReference, setHasReference] = React.useState(false);
870
+
871
+ const { refs, floatingStyles } = useFloating({
872
+ open: state.isOpen && hasReference,
873
+ placement: "bottom-start",
874
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
875
+ whileElementsMounted: autoUpdate,
876
+ });
877
+
878
+ React.useEffect(() => {
879
+ if (state.clientRect) {
880
+ const clientRectFn = state.clientRect;
881
+ refs.setReference({
882
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect(),
883
+ });
884
+ setHasReference(true);
885
+ } else {
886
+ setHasReference(false);
887
+ }
888
+ }, [state.clientRect, refs]);
889
+
890
+ // Reset reference tracking when menu closes
891
+ React.useEffect(() => {
892
+ if (!state.isOpen) setHasReference(false);
893
+ }, [state.isOpen]);
894
+
895
+ React.useEffect(() => {
896
+ if (!state.isOpen || !hasReference) return;
897
+ const container = containerRef.current;
898
+ if (!container) return;
899
+ const selected = container.querySelector(`[data-index="${state.selectedIndex}"]`);
900
+ if (selected instanceof HTMLElement) {
901
+ // Use scrollIntoView only within the menu container to avoid scrolling the page
902
+ const containerTop = container.scrollTop;
903
+ const containerBottom = containerTop + container.clientHeight;
904
+ const itemTop = selected.offsetTop;
905
+ const itemBottom = itemTop + selected.offsetHeight;
906
+ if (itemTop < containerTop) {
907
+ container.scrollTop = itemTop;
908
+ } else if (itemBottom > containerBottom) {
909
+ container.scrollTop = itemBottom - container.clientHeight;
910
+ }
911
+ }
912
+ }, [state.selectedIndex, state.isOpen, hasReference]);
913
+
914
+ if (!state.isOpen || !hasReference) return null;
915
+
916
+ return createPortal(
917
+ <div
918
+ ref={(node) => {
919
+ containerRef.current = node;
920
+ refs.setFloating(node);
921
+ }}
922
+ style={{
923
+ ...floatingStyles,
924
+ zIndex: 100,
925
+ borderRadius: "8px",
926
+ border: "1px solid #d1d5db",
927
+ background: "white",
928
+ padding: "4px",
929
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
930
+ minWidth: "220px",
931
+ maxHeight: "300px",
932
+ overflowY: "auto",
933
+ }}
934
+ className="dineway-slash-menu"
935
+ >
936
+ {state.items.length === 0 ? (
937
+ <p
938
+ style={{
939
+ padding: "8px 12px",
940
+ fontSize: "13px",
941
+ color: "#9ca3af",
942
+ margin: 0,
943
+ }}
944
+ >
945
+ No results
946
+ </p>
947
+ ) : (
948
+ state.items.map((item, index) => (
949
+ <button
950
+ key={item.id}
951
+ type="button"
952
+ data-index={index}
953
+ style={{
954
+ display: "flex",
955
+ alignItems: "center",
956
+ gap: "12px",
957
+ width: "100%",
958
+ padding: "8px 12px",
959
+ fontSize: "13px",
960
+ borderRadius: "4px",
961
+ border: "none",
962
+ textAlign: "left",
963
+ cursor: "pointer",
964
+ background: index === state.selectedIndex ? "#f3f4f6" : "transparent",
965
+ }}
966
+ onClick={() => onCommand(item)}
967
+ onMouseEnter={() => setSelectedIndex(index)}
968
+ >
969
+ <span
970
+ style={{
971
+ width: "24px",
972
+ height: "24px",
973
+ display: "flex",
974
+ alignItems: "center",
975
+ justifyContent: "center",
976
+ flexShrink: 0,
977
+ fontSize: "14px",
978
+ fontWeight: 600,
979
+ color: "#6b7280",
980
+ background: "#f3f4f6",
981
+ borderRadius: "4px",
982
+ }}
983
+ >
984
+ {item.icon}
985
+ </span>
986
+ <span style={{ display: "flex", flexDirection: "column" }}>
987
+ <span style={{ fontWeight: 500 }}>{item.title}</span>
988
+ <span style={{ fontSize: "12px", color: "#9ca3af" }}>{item.description}</span>
989
+ </span>
990
+ </button>
991
+ ))
992
+ )}
993
+ </div>,
994
+ document.body,
995
+ );
996
+ }
997
+
998
+ // ── Media Picker ───────────────────────────────────────────────────
999
+
1000
+ interface MediaItemData {
1001
+ id: string;
1002
+ filename: string;
1003
+ mimeType: string;
1004
+ url: string;
1005
+ storageKey?: string;
1006
+ width?: number;
1007
+ height?: number;
1008
+ alt?: string;
1009
+ provider?: string;
1010
+ previewUrl?: string;
1011
+ meta?: Record<string, unknown>;
1012
+ }
1013
+
1014
+ interface ProviderInfo {
1015
+ id: string;
1016
+ name: string;
1017
+ icon?: string;
1018
+ capabilities: { browse: boolean; search: boolean; upload: boolean; delete: boolean };
1019
+ }
1020
+
1021
+ const API_BASE = "/_dineway/api";
1022
+
1023
+ async function ecFetch(url: string, init?: RequestInit): Promise<Response> {
1024
+ const base = new Headers(init?.headers);
1025
+ base.set("X-Dineway-Request", "1");
1026
+ return fetch(url, {
1027
+ credentials: "same-origin",
1028
+ ...init,
1029
+ headers: base,
1030
+ });
1031
+ }
1032
+
1033
+ function InlineMediaPicker({
1034
+ open,
1035
+ onClose,
1036
+ onSelect,
1037
+ }: {
1038
+ open: boolean;
1039
+ onClose: () => void;
1040
+ onSelect: (item: MediaItemData) => void;
1041
+ }) {
1042
+ const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
1043
+ const [activeProvider, setActiveProvider] = React.useState("local");
1044
+ const [items, setItems] = React.useState<MediaItemData[]>([]);
1045
+ const [loading, setLoading] = React.useState(false);
1046
+ const [uploading, setUploading] = React.useState(false);
1047
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
1048
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
1049
+
1050
+ // Fetch providers on open
1051
+ React.useEffect(() => {
1052
+ if (!open) return;
1053
+ setSelectedId(null);
1054
+ setActiveProvider("local");
1055
+ ecFetch(`${API_BASE}/media/providers`)
1056
+ .then((r) => r.json())
1057
+ .then((d) => setProviders(d.data.items ?? []))
1058
+ .catch(() => setProviders([]));
1059
+ }, [open]);
1060
+
1061
+ // Fetch items when provider changes
1062
+ React.useEffect(() => {
1063
+ if (!open) return;
1064
+ setLoading(true);
1065
+ setSelectedId(null);
1066
+
1067
+ const url =
1068
+ activeProvider === "local"
1069
+ ? `${API_BASE}/media?mimeType=image/&limit=50`
1070
+ : `${API_BASE}/media/providers/${activeProvider}?mimeType=image/&limit=50`;
1071
+
1072
+ void (async () => {
1073
+ try {
1074
+ const r = await ecFetch(url);
1075
+ const d = await r.json();
1076
+ const raw = d.data.items ?? [];
1077
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- API response items mapped to MediaItem shape
1078
+ const typedRaw = raw as Array<{
1079
+ id: string;
1080
+ filename?: string;
1081
+ mimeType?: string;
1082
+ url?: string;
1083
+ previewUrl?: string;
1084
+ storageKey?: string;
1085
+ width?: number;
1086
+ height?: number;
1087
+ alt?: string;
1088
+ meta?: Record<string, unknown>;
1089
+ }>;
1090
+ setItems(
1091
+ typedRaw.map((item) => ({
1092
+ id: item.id,
1093
+ filename: item.filename || "",
1094
+ mimeType: item.mimeType || "image/unknown",
1095
+ url:
1096
+ item.url ||
1097
+ item.previewUrl ||
1098
+ (item.storageKey ? `${API_BASE}/media/file/${item.storageKey}` : ""),
1099
+ storageKey: item.storageKey,
1100
+ width: item.width,
1101
+ height: item.height,
1102
+ alt: item.alt,
1103
+ provider: activeProvider === "local" ? undefined : activeProvider,
1104
+ previewUrl: item.previewUrl,
1105
+ meta: item.meta,
1106
+ })),
1107
+ );
1108
+ } catch {
1109
+ setItems([]);
1110
+ } finally {
1111
+ setLoading(false);
1112
+ }
1113
+ })();
1114
+ }, [open, activeProvider]);
1115
+
1116
+ const handleUpload = async (file: File) => {
1117
+ setUploading(true);
1118
+ try {
1119
+ // Detect dimensions and generate a thumbnail for large images to
1120
+ // avoid OOM in server-side blurhash generation on constrained runtimes.
1121
+ const dims = await new Promise<{
1122
+ width?: number;
1123
+ height?: number;
1124
+ thumbnail?: Blob;
1125
+ }>((resolve) => {
1126
+ if (!file.type.startsWith("image/")) return resolve({});
1127
+ const img = new window.Image();
1128
+ img.onload = () => {
1129
+ const w = img.naturalWidth;
1130
+ const h = img.naturalHeight;
1131
+ // 32 MB RGBA threshold — matches server MAX_DECODED_BYTES
1132
+ if (w * h * 4 > 32 * 1024 * 1024) {
1133
+ const { width: thumbW, height: thumbH } = computeThumbnailSize(w, h);
1134
+ try {
1135
+ const canvas = document.createElement("canvas");
1136
+ canvas.width = thumbW;
1137
+ canvas.height = thumbH;
1138
+ const ctx = canvas.getContext("2d");
1139
+ if (ctx) {
1140
+ ctx.drawImage(img, 0, 0, thumbW, thumbH);
1141
+ canvas.toBlob((blob) => {
1142
+ URL.revokeObjectURL(img.src);
1143
+ resolve({ width: w, height: h, thumbnail: blob ?? undefined });
1144
+ }, "image/png");
1145
+ return;
1146
+ }
1147
+ } catch {
1148
+ // Canvas allocation or draw failed — fall through to no-thumbnail path
1149
+ }
1150
+ }
1151
+ URL.revokeObjectURL(img.src);
1152
+ resolve({ width: w, height: h });
1153
+ };
1154
+ img.onerror = () => {
1155
+ resolve({});
1156
+ URL.revokeObjectURL(img.src);
1157
+ };
1158
+ img.src = URL.createObjectURL(file);
1159
+ });
1160
+
1161
+ let item: MediaItemData;
1162
+
1163
+ if (activeProvider === "local") {
1164
+ const formData = new FormData();
1165
+ formData.append("file", file);
1166
+ if (dims.width) formData.append("width", String(dims.width));
1167
+ if (dims.height) formData.append("height", String(dims.height));
1168
+ if (dims.thumbnail) formData.append("thumbnail", dims.thumbnail, "thumb.png");
1169
+ const res = await ecFetch(`${API_BASE}/media`, { method: "POST", body: formData });
1170
+ const data = await res.json();
1171
+ const unwrapped = data.data ?? data;
1172
+ if (!unwrapped.item) throw new Error("Upload failed");
1173
+ const raw = unwrapped.item;
1174
+ item = {
1175
+ id: raw.id,
1176
+ filename: raw.filename || file.name,
1177
+ mimeType: raw.mimeType || file.type,
1178
+ url: raw.url || raw.previewUrl || `${API_BASE}/media/file/${raw.storageKey}`,
1179
+ storageKey: raw.storageKey,
1180
+ width: raw.width || dims.width,
1181
+ height: raw.height || dims.height,
1182
+ alt: raw.alt,
1183
+ };
1184
+ } else {
1185
+ const formData = new FormData();
1186
+ formData.append("file", file);
1187
+ const res = await ecFetch(`${API_BASE}/media/providers/${activeProvider}`, {
1188
+ method: "POST",
1189
+ body: formData,
1190
+ });
1191
+ const data = await res.json();
1192
+ const unwrapped = data.data ?? data;
1193
+ if (!unwrapped.item) throw new Error("Upload failed");
1194
+ const raw = unwrapped.item;
1195
+ item = {
1196
+ id: raw.id,
1197
+ filename: raw.filename || file.name,
1198
+ mimeType: raw.mimeType || file.type,
1199
+ url: raw.previewUrl || "",
1200
+ width: raw.width || dims.width,
1201
+ height: raw.height || dims.height,
1202
+ alt: raw.alt,
1203
+ provider: activeProvider,
1204
+ previewUrl: raw.previewUrl,
1205
+ meta: raw.meta,
1206
+ };
1207
+ }
1208
+
1209
+ setItems((prev) => [item, ...prev]);
1210
+ setSelectedId(item.id);
1211
+ } catch (err) {
1212
+ console.error("Upload failed:", err);
1213
+ } finally {
1214
+ setUploading(false);
1215
+ }
1216
+ };
1217
+
1218
+ const handleConfirm = () => {
1219
+ const item = items.find((i) => i.id === selectedId);
1220
+ if (item) onSelect(item);
1221
+ };
1222
+
1223
+ const providerTabs = React.useMemo(() => {
1224
+ const tabs: Array<{ id: string; name: string; icon?: string }> = [
1225
+ { id: "local", name: "Library" },
1226
+ ];
1227
+ for (const p of providers) {
1228
+ if (p.id !== "local") tabs.push({ id: p.id, name: p.name, icon: p.icon });
1229
+ }
1230
+ return tabs;
1231
+ }, [providers]);
1232
+
1233
+ if (!open) return null;
1234
+
1235
+ return createPortal(
1236
+ <div
1237
+ style={{
1238
+ position: "fixed",
1239
+ inset: 0,
1240
+ zIndex: 10000,
1241
+ display: "flex",
1242
+ alignItems: "center",
1243
+ justifyContent: "center",
1244
+ background: "rgba(0,0,0,0.5)",
1245
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
1246
+ }}
1247
+ onClick={(e) => {
1248
+ if (e.target === e.currentTarget) onClose();
1249
+ }}
1250
+ >
1251
+ <div
1252
+ style={{
1253
+ background: "white",
1254
+ borderRadius: "12px",
1255
+ width: "min(700px, 90vw)",
1256
+ maxHeight: "80vh",
1257
+ display: "flex",
1258
+ flexDirection: "column",
1259
+ overflow: "hidden",
1260
+ boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
1261
+ }}
1262
+ className="dineway-media-picker"
1263
+ >
1264
+ {/* Header */}
1265
+ <div
1266
+ style={{
1267
+ display: "flex",
1268
+ alignItems: "center",
1269
+ justifyContent: "space-between",
1270
+ padding: "16px 20px",
1271
+ borderBottom: "1px solid #e5e7eb",
1272
+ }}
1273
+ >
1274
+ <span style={{ fontSize: "16px", fontWeight: 600 }}>Insert Image</span>
1275
+ <button
1276
+ type="button"
1277
+ onClick={onClose}
1278
+ style={{
1279
+ background: "none",
1280
+ border: "none",
1281
+ cursor: "pointer",
1282
+ fontSize: "18px",
1283
+ padding: "4px 8px",
1284
+ borderRadius: "4px",
1285
+ color: "#6b7280",
1286
+ }}
1287
+ aria-label="Close"
1288
+ >
1289
+
1290
+ </button>
1291
+ </div>
1292
+
1293
+ {/* Provider tabs */}
1294
+ {providerTabs.length > 1 && (
1295
+ <div
1296
+ style={{
1297
+ display: "flex",
1298
+ gap: "6px",
1299
+ padding: "12px 20px",
1300
+ borderBottom: "1px solid #e5e7eb",
1301
+ flexWrap: "wrap",
1302
+ }}
1303
+ >
1304
+ {providerTabs.map((tab) => (
1305
+ <button
1306
+ key={tab.id}
1307
+ type="button"
1308
+ onClick={() => setActiveProvider(tab.id)}
1309
+ style={{
1310
+ padding: "6px 14px",
1311
+ fontSize: "13px",
1312
+ fontWeight: 500,
1313
+ borderRadius: "6px",
1314
+ border: "none",
1315
+ cursor: "pointer",
1316
+ background: activeProvider === tab.id ? "#3b82f6" : "#f3f4f6",
1317
+ color: activeProvider === tab.id ? "white" : "#4b5563",
1318
+ }}
1319
+ >
1320
+ {tab.icon && (
1321
+ <span
1322
+ style={{ marginRight: "6px", display: "inline-flex", alignItems: "center" }}
1323
+ >
1324
+ {tab.icon.startsWith("data:") || tab.icon.startsWith("http") ? (
1325
+ <img src={tab.icon} alt="" style={{ width: "16px", height: "16px" }} />
1326
+ ) : (
1327
+ tab.icon
1328
+ )}
1329
+ </span>
1330
+ )}
1331
+ {tab.name}
1332
+ </button>
1333
+ ))}
1334
+ </div>
1335
+ )}
1336
+
1337
+ {/* Upload bar */}
1338
+ <div
1339
+ style={{
1340
+ display: "flex",
1341
+ alignItems: "center",
1342
+ justifyContent: "space-between",
1343
+ padding: "12px 20px",
1344
+ borderBottom: "1px solid #e5e7eb",
1345
+ }}
1346
+ >
1347
+ <span style={{ fontSize: "13px", color: "#6b7280" }}>
1348
+ {loading ? "Loading…" : `${items.length} image${items.length !== 1 ? "s" : ""}`}
1349
+ </span>
1350
+ <button
1351
+ type="button"
1352
+ onClick={() => fileInputRef.current?.click()}
1353
+ disabled={uploading}
1354
+ style={{
1355
+ padding: "6px 14px",
1356
+ fontSize: "13px",
1357
+ fontWeight: 500,
1358
+ borderRadius: "6px",
1359
+ border: "1px solid #d1d5db",
1360
+ cursor: uploading ? "not-allowed" : "pointer",
1361
+ background: "white",
1362
+ color: "#374151",
1363
+ opacity: uploading ? 0.6 : 1,
1364
+ }}
1365
+ >
1366
+ {uploading ? "Uploading…" : "Upload"}
1367
+ </button>
1368
+ <input
1369
+ ref={fileInputRef}
1370
+ type="file"
1371
+ accept="image/*"
1372
+ style={{ display: "none" }}
1373
+ onChange={(e) => {
1374
+ const file = e.target.files?.[0];
1375
+ if (file) void handleUpload(file);
1376
+ if (fileInputRef.current) fileInputRef.current.value = "";
1377
+ }}
1378
+ />
1379
+ </div>
1380
+
1381
+ {/* Grid */}
1382
+ <div
1383
+ style={{
1384
+ flex: 1,
1385
+ overflowY: "auto",
1386
+ padding: "16px 20px",
1387
+ minHeight: "250px",
1388
+ }}
1389
+ >
1390
+ {loading ? (
1391
+ <div
1392
+ style={{
1393
+ display: "flex",
1394
+ alignItems: "center",
1395
+ justifyContent: "center",
1396
+ height: "200px",
1397
+ color: "#9ca3af",
1398
+ fontSize: "14px",
1399
+ }}
1400
+ >
1401
+ Loading…
1402
+ </div>
1403
+ ) : items.length === 0 ? (
1404
+ <div
1405
+ style={{
1406
+ display: "flex",
1407
+ flexDirection: "column",
1408
+ alignItems: "center",
1409
+ justifyContent: "center",
1410
+ height: "200px",
1411
+ color: "#9ca3af",
1412
+ fontSize: "14px",
1413
+ textAlign: "center",
1414
+ }}
1415
+ >
1416
+ <div style={{ fontSize: "32px", marginBottom: "8px" }}>🖼</div>
1417
+ No images found
1418
+ <button
1419
+ type="button"
1420
+ onClick={() => fileInputRef.current?.click()}
1421
+ style={{
1422
+ marginTop: "12px",
1423
+ padding: "8px 16px",
1424
+ fontSize: "13px",
1425
+ borderRadius: "6px",
1426
+ border: "1px solid #d1d5db",
1427
+ background: "white",
1428
+ cursor: "pointer",
1429
+ color: "#374151",
1430
+ }}
1431
+ >
1432
+ Upload an image
1433
+ </button>
1434
+ </div>
1435
+ ) : (
1436
+ <div
1437
+ style={{
1438
+ display: "grid",
1439
+ gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
1440
+ gap: "10px",
1441
+ }}
1442
+ >
1443
+ {items.map((item) => {
1444
+ const isSelected = selectedId === item.id;
1445
+ const thumb = item.url || item.previewUrl || "";
1446
+ return (
1447
+ <button
1448
+ key={item.id}
1449
+ type="button"
1450
+ onClick={() => setSelectedId(item.id)}
1451
+ onDoubleClick={() => onSelect(item)}
1452
+ style={{
1453
+ position: "relative",
1454
+ aspectRatio: "1",
1455
+ borderRadius: "8px",
1456
+ border: isSelected ? "2px solid #3b82f6" : "2px solid transparent",
1457
+ overflow: "hidden",
1458
+ cursor: "pointer",
1459
+ padding: 0,
1460
+ background: "#f3f4f6",
1461
+ outline: isSelected ? "2px solid rgba(59,130,246,0.3)" : "none",
1462
+ outlineOffset: "1px",
1463
+ }}
1464
+ aria-label={item.filename}
1465
+ >
1466
+ {thumb ? (
1467
+ <img
1468
+ src={thumb}
1469
+ alt=""
1470
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
1471
+ />
1472
+ ) : (
1473
+ <div
1474
+ style={{
1475
+ width: "100%",
1476
+ height: "100%",
1477
+ display: "flex",
1478
+ alignItems: "center",
1479
+ justifyContent: "center",
1480
+ fontSize: "24px",
1481
+ }}
1482
+ >
1483
+ 🖼
1484
+ </div>
1485
+ )}
1486
+ {isSelected && (
1487
+ <div
1488
+ style={{
1489
+ position: "absolute",
1490
+ inset: 0,
1491
+ background: "rgba(59,130,246,0.15)",
1492
+ display: "flex",
1493
+ alignItems: "center",
1494
+ justifyContent: "center",
1495
+ }}
1496
+ >
1497
+ <div
1498
+ style={{
1499
+ width: "24px",
1500
+ height: "24px",
1501
+ borderRadius: "50%",
1502
+ background: "#3b82f6",
1503
+ color: "white",
1504
+ display: "flex",
1505
+ alignItems: "center",
1506
+ justifyContent: "center",
1507
+ fontSize: "14px",
1508
+ }}
1509
+ >
1510
+
1511
+ </div>
1512
+ </div>
1513
+ )}
1514
+ <div
1515
+ style={{
1516
+ position: "absolute",
1517
+ bottom: 0,
1518
+ left: 0,
1519
+ right: 0,
1520
+ background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
1521
+ padding: "16px 6px 4px",
1522
+ }}
1523
+ >
1524
+ <div
1525
+ style={{
1526
+ fontSize: "11px",
1527
+ color: "white",
1528
+ overflow: "hidden",
1529
+ textOverflow: "ellipsis",
1530
+ whiteSpace: "nowrap",
1531
+ }}
1532
+ >
1533
+ {item.filename}
1534
+ </div>
1535
+ </div>
1536
+ </button>
1537
+ );
1538
+ })}
1539
+ </div>
1540
+ )}
1541
+ </div>
1542
+
1543
+ {/* Footer */}
1544
+ <div
1545
+ style={{
1546
+ display: "flex",
1547
+ justifyContent: "flex-end",
1548
+ gap: "8px",
1549
+ padding: "12px 20px",
1550
+ borderTop: "1px solid #e5e7eb",
1551
+ }}
1552
+ >
1553
+ <button
1554
+ type="button"
1555
+ onClick={onClose}
1556
+ style={{
1557
+ padding: "8px 16px",
1558
+ fontSize: "13px",
1559
+ fontWeight: 500,
1560
+ borderRadius: "6px",
1561
+ border: "1px solid #d1d5db",
1562
+ background: "white",
1563
+ cursor: "pointer",
1564
+ color: "#374151",
1565
+ }}
1566
+ >
1567
+ Cancel
1568
+ </button>
1569
+ <button
1570
+ type="button"
1571
+ onClick={handleConfirm}
1572
+ disabled={!selectedId}
1573
+ style={{
1574
+ padding: "8px 16px",
1575
+ fontSize: "13px",
1576
+ fontWeight: 500,
1577
+ borderRadius: "6px",
1578
+ border: "none",
1579
+ background: selectedId ? "#3b82f6" : "#93c5fd",
1580
+ color: "white",
1581
+ cursor: selectedId ? "pointer" : "not-allowed",
1582
+ }}
1583
+ >
1584
+ Insert
1585
+ </button>
1586
+ </div>
1587
+ </div>
1588
+ </div>,
1589
+ document.body,
1590
+ );
1591
+ }
1592
+
1593
+ // ── Component ──────────────────────────────────────────────────────
1594
+
1595
+ export interface InlinePortableTextEditorProps {
1596
+ value: PTBlock[];
1597
+ collection: string;
1598
+ entryId: string;
1599
+ field: string;
1600
+ }
1601
+
1602
+ export function InlinePortableTextEditor({
1603
+ value,
1604
+ collection,
1605
+ entryId,
1606
+ field,
1607
+ }: InlinePortableTextEditorProps) {
1608
+ const initialRef = React.useRef(value);
1609
+ const savingRef = React.useRef(false);
1610
+ const editorRef = React.useRef<ReturnType<typeof useEditor>>(null);
1611
+
1612
+ // Media picker state
1613
+ const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false);
1614
+
1615
+ // Listen for the slash command's media picker event
1616
+ React.useEffect(() => {
1617
+ const handler = () => setMediaPickerOpen(true);
1618
+ document.addEventListener("dineway:open-media-picker", handler);
1619
+ return () => document.removeEventListener("dineway:open-media-picker", handler);
1620
+ }, []);
1621
+
1622
+ // Slash menu state — use ref to avoid re-creating the extension on state change
1623
+ const [slashMenuState, setSlashMenuState] = React.useState<SlashMenuState>(initialSlashMenuState);
1624
+ const slashMenuStateRef = React.useRef(slashMenuState);
1625
+ slashMenuStateRef.current = slashMenuState;
1626
+
1627
+ const filterCommandsRef = React.useRef((query: string): SlashCommandItem[] => {
1628
+ const q = query.toLowerCase();
1629
+ return slashCommands.filter(
1630
+ (cmd) =>
1631
+ cmd.title.toLowerCase().includes(q) ||
1632
+ cmd.description.toLowerCase().includes(q) ||
1633
+ cmd.aliases?.some((a) => a.toLowerCase().includes(q)),
1634
+ );
1635
+ });
1636
+
1637
+ const initialContent = React.useMemo(
1638
+ () => portableTextToPM(value || []),
1639
+ [], // Only compute once on mount
1640
+ );
1641
+
1642
+ const getBlocks = React.useCallback((): PTBlock[] => {
1643
+ const editor = editorRef.current;
1644
+ if (!editor) return initialRef.current;
1645
+ const json: unknown = editor.getJSON();
1646
+ if (!isPMNode(json)) return initialRef.current;
1647
+ return pmToPortableText(json);
1648
+ }, []);
1649
+
1650
+ const save = React.useCallback(async () => {
1651
+ if (savingRef.current) return;
1652
+
1653
+ const current = JSON.stringify(getBlocks());
1654
+ const initial = JSON.stringify(initialRef.current);
1655
+ if (current === initial) return;
1656
+
1657
+ savingRef.current = true;
1658
+ try {
1659
+ const res = await fetch(
1660
+ `/_dineway/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(entryId)}`,
1661
+ {
1662
+ method: "PUT",
1663
+ credentials: "same-origin",
1664
+ headers: { "Content-Type": "application/json", "X-Dineway-Request": "1" },
1665
+ body: JSON.stringify({ data: { [field]: getBlocks() } }),
1666
+ },
1667
+ );
1668
+
1669
+ if (res.ok) {
1670
+ initialRef.current = getBlocks();
1671
+ document.dispatchEvent(new CustomEvent("dineway:save", { detail: { state: "saved" } }));
1672
+ document.dispatchEvent(
1673
+ new CustomEvent("dineway:content-changed", {
1674
+ detail: { collection, id: entryId },
1675
+ }),
1676
+ );
1677
+ } else {
1678
+ document.dispatchEvent(new CustomEvent("dineway:save", { detail: { state: "error" } }));
1679
+ console.error("Save failed:", res.status);
1680
+ }
1681
+ } catch (err) {
1682
+ document.dispatchEvent(new CustomEvent("dineway:save", { detail: { state: "error" } }));
1683
+ console.error("Save failed:", err);
1684
+ } finally {
1685
+ savingRef.current = false;
1686
+ }
1687
+ }, [collection, entryId, field, getBlocks]);
1688
+
1689
+ // Create slash commands extension once — uses refs to avoid re-render loop
1690
+ const slashCommandsExtension = React.useMemo(
1691
+ () =>
1692
+ createSlashCommandsExtension({
1693
+ filterCommands: (query: string) => filterCommandsRef.current(query),
1694
+ onStateChange: setSlashMenuState,
1695
+ getState: () => slashMenuStateRef.current,
1696
+ }),
1697
+ [],
1698
+ );
1699
+
1700
+ const editor = useEditor({
1701
+ extensions: [
1702
+ StarterKit.configure({
1703
+ heading: { levels: [1, 2, 3] },
1704
+ dropcursor: { color: "#3b82f6", width: 2 },
1705
+ }),
1706
+ Image.extend({
1707
+ addAttributes() {
1708
+ return {
1709
+ ...this.parent?.(),
1710
+ mediaId: { default: null },
1711
+ provider: { default: null },
1712
+ width: { default: null },
1713
+ height: { default: null },
1714
+ };
1715
+ },
1716
+ }),
1717
+ Underline,
1718
+ Link.configure({
1719
+ openOnClick: false,
1720
+ HTMLAttributes: { class: "underline text-blue-600 dark:text-blue-400" },
1721
+ }),
1722
+ Placeholder.configure({
1723
+ includeChildren: true,
1724
+ placeholder: ({ node }) => {
1725
+ if (node.type.name === "paragraph") return "Type / for commands...";
1726
+ return "";
1727
+ },
1728
+ }),
1729
+ TextAlign.configure({
1730
+ types: ["heading", "paragraph"],
1731
+ }),
1732
+ Focus.configure({
1733
+ className: "has-focus",
1734
+ mode: "all",
1735
+ }),
1736
+ Typography,
1737
+ slashCommandsExtension,
1738
+ ],
1739
+ content: initialContent,
1740
+ immediatelyRender: false,
1741
+ editorProps: {
1742
+ attributes: {
1743
+ class: "prose prose-sm sm:prose-base dark:prose-invert max-w-none dineway-inline-editor",
1744
+ },
1745
+ },
1746
+ onUpdate: () => {
1747
+ document.dispatchEvent(new CustomEvent("dineway:save", { detail: { state: "unsaved" } }));
1748
+ },
1749
+ });
1750
+
1751
+ // Store editor ref for getBlocks
1752
+ React.useEffect(() => {
1753
+ editorRef.current = editor;
1754
+ }, [editor]);
1755
+
1756
+ // Slash menu command handler
1757
+ const handleSlashCommand = React.useCallback(
1758
+ (item: SlashCommandItem) => {
1759
+ if (!editor || !slashMenuStateRef.current.range) return;
1760
+ item.command({ editor, range: slashMenuStateRef.current.range });
1761
+ setSlashMenuState((prev) => ({ ...prev, isOpen: false }));
1762
+ },
1763
+ [editor],
1764
+ );
1765
+
1766
+ // Handle media selection from the picker
1767
+ const handleMediaSelect = React.useCallback(
1768
+ (item: MediaItemData) => {
1769
+ if (!editor) return;
1770
+ const src =
1771
+ item.url || item.previewUrl || `/_dineway/api/media/file/${item.storageKey || item.id}`;
1772
+ editor
1773
+ .chain()
1774
+ .focus()
1775
+ .setImage({
1776
+ src,
1777
+ alt: item.alt || item.filename || "",
1778
+ mediaId: item.id,
1779
+ width: item.width,
1780
+ height: item.height,
1781
+ })
1782
+ .run();
1783
+ setMediaPickerOpen(false);
1784
+ void save();
1785
+ },
1786
+ [editor, save],
1787
+ );
1788
+
1789
+ // Save on blur — but not when interacting with slash menu or media picker
1790
+ const handleBlur = React.useCallback(
1791
+ (e: React.FocusEvent<HTMLDivElement>) => {
1792
+ if (mediaPickerOpen) return;
1793
+ const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
1794
+ if (related && e.currentTarget.contains(related)) return;
1795
+ // Don't save if focus moved to the slash menu (portalled to body)
1796
+ if (related?.closest(".dineway-slash-menu")) return;
1797
+ if (related?.closest(".dineway-media-picker")) return;
1798
+ save();
1799
+ },
1800
+ [save, mediaPickerOpen],
1801
+ );
1802
+
1803
+ if (!editor) return null;
1804
+
1805
+ return (
1806
+ <div onBlur={handleBlur}>
1807
+ <InlineBubbleMenu editor={editor} />
1808
+ <EditorContent editor={editor} />
1809
+ <InlineSlashMenu
1810
+ state={slashMenuState}
1811
+ onCommand={handleSlashCommand}
1812
+ setSelectedIndex={(index) =>
1813
+ setSlashMenuState((prev) => ({ ...prev, selectedIndex: index }))
1814
+ }
1815
+ />
1816
+ <InlineMediaPicker
1817
+ open={mediaPickerOpen}
1818
+ onClose={() => {
1819
+ setMediaPickerOpen(false);
1820
+ editor?.commands.focus();
1821
+ }}
1822
+ onSelect={handleMediaSelect}
1823
+ />
1824
+ <style>{`
1825
+ .dineway-bubble-menu {
1826
+ z-index: 100;
1827
+ display: flex;
1828
+ align-items: center;
1829
+ gap: 2px;
1830
+ padding: 4px;
1831
+ border-radius: 8px;
1832
+ border: 1px solid #d1d5db;
1833
+ background: white;
1834
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1835
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1836
+ }
1837
+ .dineway-bubble-btn {
1838
+ display: inline-flex;
1839
+ align-items: center;
1840
+ justify-content: center;
1841
+ width: 32px;
1842
+ height: 32px;
1843
+ border-radius: 6px;
1844
+ border: none;
1845
+ background: transparent;
1846
+ cursor: pointer;
1847
+ color: inherit;
1848
+ font-size: 14px;
1849
+ line-height: 1;
1850
+ padding: 0;
1851
+ }
1852
+ .dineway-bubble-btn:hover {
1853
+ background: #f3f4f6;
1854
+ }
1855
+ .dineway-bubble-btn--active {
1856
+ background: #dbeafe;
1857
+ color: #1d4ed8;
1858
+ }
1859
+ .dineway-bubble-btn--active:hover {
1860
+ background: #bfdbfe;
1861
+ }
1862
+ .dineway-bubble-btn--danger {
1863
+ color: #dc2626;
1864
+ }
1865
+ .dineway-bubble-divider {
1866
+ display: block;
1867
+ width: 1px;
1868
+ height: 20px;
1869
+ background: #d1d5db;
1870
+ margin: 0 4px;
1871
+ }
1872
+ .dineway-bubble-link-input {
1873
+ height: 28px;
1874
+ width: 200px;
1875
+ font-size: 13px;
1876
+ padding: 0 8px;
1877
+ border: 1px solid #d1d5db;
1878
+ border-radius: 4px;
1879
+ outline: none;
1880
+ background: white;
1881
+ color: inherit;
1882
+ font-family: inherit;
1883
+ }
1884
+ .dineway-bubble-link-input:focus {
1885
+ border-color: #3b82f6;
1886
+ }
1887
+ @media (prefers-color-scheme: dark) {
1888
+ .dineway-bubble-menu {
1889
+ background: #1f2937;
1890
+ border-color: #374151;
1891
+ color: #e5e7eb;
1892
+ }
1893
+ .dineway-bubble-btn:hover {
1894
+ background: #374151;
1895
+ }
1896
+ .dineway-bubble-btn--active {
1897
+ background: #1e3a5f;
1898
+ color: #93c5fd;
1899
+ }
1900
+ .dineway-bubble-btn--active:hover {
1901
+ background: #1e40af;
1902
+ }
1903
+ .dineway-bubble-divider {
1904
+ background: #4b5563;
1905
+ }
1906
+ .dineway-bubble-link-input {
1907
+ background: #111827;
1908
+ border-color: #4b5563;
1909
+ color: #e5e7eb;
1910
+ }
1911
+ .dineway-bubble-link-input:focus {
1912
+ border-color: #60a5fa;
1913
+ }
1914
+ .dineway-slash-menu {
1915
+ background: #1f2937 !important;
1916
+ border-color: #374151 !important;
1917
+ color: #e5e7eb !important;
1918
+ }
1919
+ .dineway-slash-menu button:hover,
1920
+ .dineway-slash-menu button[style*="background: rgb(243, 244, 246)"] {
1921
+ background: #374151 !important;
1922
+ }
1923
+ .dineway-media-picker {
1924
+ background: #1f2937 !important;
1925
+ color: #e5e7eb !important;
1926
+ }
1927
+ .dineway-media-picker button {
1928
+ color: #e5e7eb !important;
1929
+ }
1930
+ }
1931
+ .dineway-inline-editor:focus {
1932
+ outline: none;
1933
+ }
1934
+ `}</style>
1935
+ </div>
1936
+ );
1937
+ }