@btst/stack 2.8.1 → 2.9.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 (196) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/client/index.d.cts +58 -1
  74. package/dist/plugins/blog/client/index.d.mts +58 -1
  75. package/dist/plugins/blog/client/index.d.ts +58 -1
  76. package/dist/plugins/cms/client/index.d.cts +73 -3
  77. package/dist/plugins/cms/client/index.d.mts +73 -3
  78. package/dist/plugins/cms/client/index.d.ts +73 -3
  79. package/dist/plugins/kanban/api/index.d.cts +1 -1
  80. package/dist/plugins/kanban/api/index.d.mts +1 -1
  81. package/dist/plugins/kanban/api/index.d.ts +1 -1
  82. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  83. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  84. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  85. package/dist/plugins/kanban/client/index.d.cts +1 -1
  86. package/dist/plugins/kanban/client/index.d.mts +1 -1
  87. package/dist/plugins/kanban/client/index.d.ts +1 -1
  88. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  89. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  90. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  91. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  92. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  93. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  94. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  95. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  96. package/dist/plugins/media/api/adapters/vercel-blob.cjs +53 -0
  97. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  98. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  99. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  100. package/dist/plugins/media/api/adapters/vercel-blob.mjs +51 -0
  101. package/dist/plugins/media/api/index.cjs +26 -0
  102. package/dist/plugins/media/api/index.d.cts +116 -0
  103. package/dist/plugins/media/api/index.d.mts +116 -0
  104. package/dist/plugins/media/api/index.d.ts +116 -0
  105. package/dist/plugins/media/api/index.mjs +6 -0
  106. package/dist/plugins/media/client/components/index.cjs +10 -0
  107. package/dist/plugins/media/client/components/index.d.cts +55 -0
  108. package/dist/plugins/media/client/components/index.d.mts +55 -0
  109. package/dist/plugins/media/client/components/index.d.ts +55 -0
  110. package/dist/plugins/media/client/components/index.mjs +2 -0
  111. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  112. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  113. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  114. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  115. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  116. package/dist/plugins/media/client/index.cjs +9 -0
  117. package/dist/plugins/media/client/index.d.cts +242 -0
  118. package/dist/plugins/media/client/index.d.mts +242 -0
  119. package/dist/plugins/media/client/index.d.ts +242 -0
  120. package/dist/plugins/media/client/index.mjs +2 -0
  121. package/dist/plugins/media/client.css +1 -0
  122. package/dist/plugins/media/query-keys.cjs +72 -0
  123. package/dist/plugins/media/query-keys.d.cts +49 -0
  124. package/dist/plugins/media/query-keys.d.mts +49 -0
  125. package/dist/plugins/media/query-keys.d.ts +49 -0
  126. package/dist/plugins/media/query-keys.mjs +70 -0
  127. package/dist/plugins/media/style.css +1 -0
  128. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  129. package/dist/shared/stack.BttDsJJn.d.cts +109 -0
  130. package/dist/shared/stack.BttDsJJn.d.mts +109 -0
  131. package/dist/shared/stack.BttDsJJn.d.ts +109 -0
  132. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  133. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  134. package/dist/shared/stack.CI8iRKKi.d.cts +286 -0
  135. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  136. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  137. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  138. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  139. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  140. package/dist/shared/stack.DJDjdG64.d.ts +286 -0
  141. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  142. package/dist/shared/stack.FgBVDSPi.d.mts +286 -0
  143. package/package.json +113 -4
  144. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  145. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  146. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  147. package/src/plugins/blog/client/overrides.ts +58 -1
  148. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  149. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  150. package/src/plugins/cms/client/overrides.ts +57 -2
  151. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  152. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  153. package/src/plugins/kanban/client/overrides.ts +25 -0
  154. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  155. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  156. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  157. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  158. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  159. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  160. package/src/plugins/media/api/adapters/local.ts +79 -0
  161. package/src/plugins/media/api/adapters/s3.ts +198 -0
  162. package/src/plugins/media/api/adapters/vercel-blob.ts +131 -0
  163. package/src/plugins/media/api/getters.ts +174 -0
  164. package/src/plugins/media/api/index.ts +41 -0
  165. package/src/plugins/media/api/mutations.ts +179 -0
  166. package/src/plugins/media/api/plugin.ts +855 -0
  167. package/src/plugins/media/api/query-key-defs.ts +41 -0
  168. package/src/plugins/media/api/serializers.ts +28 -0
  169. package/src/plugins/media/api/storage-adapter.ts +139 -0
  170. package/src/plugins/media/client/components/index.tsx +6 -0
  171. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  172. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  173. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  174. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  175. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  176. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  177. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  178. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  179. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  180. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  181. package/src/plugins/media/client/hooks/index.tsx +9 -0
  182. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  183. package/src/plugins/media/client/index.ts +4 -0
  184. package/src/plugins/media/client/overrides.ts +127 -0
  185. package/src/plugins/media/client/plugin.tsx +184 -0
  186. package/src/plugins/media/client/upload.ts +171 -0
  187. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  188. package/src/plugins/media/client.css +1 -0
  189. package/src/plugins/media/db.ts +62 -0
  190. package/src/plugins/media/query-keys.ts +96 -0
  191. package/src/plugins/media/schemas.ts +37 -0
  192. package/src/plugins/media/style.css +1 -0
  193. package/src/plugins/media/types.ts +26 -0
  194. package/dist/shared/{stack.BOokfhZD.d.cts → stack.B6S3cgwN.d.cts} +16 -16
  195. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.Bzfx-_lq.d.mts} +16 -16
  196. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.j5SFLC1d.d.ts} +16 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -414,6 +414,77 @@
414
414
  }
415
415
  },
416
416
  "./plugins/comments/css": "./dist/plugins/comments/style.css",
417
+ "./plugins/media/api": {
418
+ "import": {
419
+ "types": "./dist/plugins/media/api/index.d.ts",
420
+ "default": "./dist/plugins/media/api/index.mjs"
421
+ },
422
+ "require": {
423
+ "types": "./dist/plugins/media/api/index.d.cts",
424
+ "default": "./dist/plugins/media/api/index.cjs"
425
+ }
426
+ },
427
+ "./plugins/media/api/adapters/s3": {
428
+ "import": {
429
+ "types": "./dist/plugins/media/api/adapters/s3.d.ts",
430
+ "default": "./dist/plugins/media/api/adapters/s3.mjs"
431
+ },
432
+ "require": {
433
+ "types": "./dist/plugins/media/api/adapters/s3.d.cts",
434
+ "default": "./dist/plugins/media/api/adapters/s3.cjs"
435
+ }
436
+ },
437
+ "./plugins/media/api/adapters/vercel-blob": {
438
+ "import": {
439
+ "types": "./dist/plugins/media/api/adapters/vercel-blob.d.ts",
440
+ "default": "./dist/plugins/media/api/adapters/vercel-blob.mjs"
441
+ },
442
+ "require": {
443
+ "types": "./dist/plugins/media/api/adapters/vercel-blob.d.cts",
444
+ "default": "./dist/plugins/media/api/adapters/vercel-blob.cjs"
445
+ }
446
+ },
447
+ "./plugins/media/client": {
448
+ "import": {
449
+ "types": "./dist/plugins/media/client/index.d.ts",
450
+ "default": "./dist/plugins/media/client/index.mjs"
451
+ },
452
+ "require": {
453
+ "types": "./dist/plugins/media/client/index.d.cts",
454
+ "default": "./dist/plugins/media/client/index.cjs"
455
+ }
456
+ },
457
+ "./plugins/media/client/components": {
458
+ "import": {
459
+ "types": "./dist/plugins/media/client/components/index.d.ts",
460
+ "default": "./dist/plugins/media/client/components/index.mjs"
461
+ },
462
+ "require": {
463
+ "types": "./dist/plugins/media/client/components/index.d.cts",
464
+ "default": "./dist/plugins/media/client/components/index.cjs"
465
+ }
466
+ },
467
+ "./plugins/media/client/hooks": {
468
+ "import": {
469
+ "types": "./dist/plugins/media/client/hooks/index.d.ts",
470
+ "default": "./dist/plugins/media/client/hooks/index.mjs"
471
+ },
472
+ "require": {
473
+ "types": "./dist/plugins/media/client/hooks/index.d.cts",
474
+ "default": "./dist/plugins/media/client/hooks/index.cjs"
475
+ }
476
+ },
477
+ "./plugins/media/query-keys": {
478
+ "import": {
479
+ "types": "./dist/plugins/media/query-keys.d.ts",
480
+ "default": "./dist/plugins/media/query-keys.mjs"
481
+ },
482
+ "require": {
483
+ "types": "./dist/plugins/media/query-keys.d.cts",
484
+ "default": "./dist/plugins/media/query-keys.cjs"
485
+ }
486
+ },
487
+ "./plugins/media/css": "./dist/plugins/media/client.css",
417
488
  "./plugins/route-docs/client": {
418
489
  "import": {
419
490
  "types": "./dist/plugins/route-docs/client/index.d.ts",
@@ -610,6 +681,27 @@
610
681
  "plugins/comments/query-keys": [
611
682
  "./dist/plugins/comments/query-keys.d.ts"
612
683
  ],
684
+ "plugins/media/api": [
685
+ "./dist/plugins/media/api/index.d.ts"
686
+ ],
687
+ "plugins/media/api/adapters/s3": [
688
+ "./dist/plugins/media/api/adapters/s3.d.ts"
689
+ ],
690
+ "plugins/media/api/adapters/vercel-blob": [
691
+ "./dist/plugins/media/api/adapters/vercel-blob.d.ts"
692
+ ],
693
+ "plugins/media/client": [
694
+ "./dist/plugins/media/client/index.d.ts"
695
+ ],
696
+ "plugins/media/client/components": [
697
+ "./dist/plugins/media/client/components/index.d.ts"
698
+ ],
699
+ "plugins/media/client/hooks": [
700
+ "./dist/plugins/media/client/hooks/index.d.ts"
701
+ ],
702
+ "plugins/media/query-keys": [
703
+ "./dist/plugins/media/query-keys.d.ts"
704
+ ],
613
705
  "plugins/route-docs/client": [
614
706
  "./dist/plugins/route-docs/client/index.d.ts"
615
707
  ],
@@ -646,13 +738,17 @@
646
738
  },
647
739
  "peerDependencies": {
648
740
  "@ai-sdk/react": ">=2.0.0",
741
+ "@aws-sdk/client-s3": ">=3.0.0",
742
+ "@aws-sdk/s3-request-presigner": ">=3.0.0",
649
743
  "@btst/yar": ">=1.2.0",
650
744
  "@hookform/resolvers": ">=5.0.0",
651
745
  "@radix-ui/react-dialog": ">=1.1.0",
652
746
  "@radix-ui/react-label": ">=2.1.0",
653
747
  "@radix-ui/react-slot": ">=1.1.0",
654
748
  "@radix-ui/react-switch": ">=1.1.0",
749
+ "@tailwindcss/typography": ">=0.5.0",
655
750
  "@tanstack/react-query": "^5.0.0",
751
+ "@vercel/blob": ">=0.14.0",
656
752
  "ai": ">=5.0.0",
657
753
  "better-call": ">=1.3.2",
658
754
  "class-variance-authority": ">=0.7.0",
@@ -675,25 +771,38 @@
675
771
  "sonner": ">=2.0.0",
676
772
  "tailwind-merge": ">=2.6.0",
677
773
  "tailwindcss": ">=3.0.0",
678
- "@tailwindcss/typography": ">=0.5.0",
679
774
  "zod": ">=4.2.0"
680
775
  },
776
+ "peerDependenciesMeta": {
777
+ "@vercel/blob": {
778
+ "optional": true
779
+ },
780
+ "@aws-sdk/client-s3": {
781
+ "optional": true
782
+ },
783
+ "@aws-sdk/s3-request-presigner": {
784
+ "optional": true
785
+ }
786
+ },
681
787
  "devDependencies": {
682
- "tsx": "catalog:",
683
788
  "@ai-sdk/react": "^2.0.94",
789
+ "@aws-sdk/client-s3": "^3.1011.0",
790
+ "@aws-sdk/s3-request-presigner": "^3.1011.0",
684
791
  "@btst/adapter-memory": "2.1.1",
685
792
  "@btst/yar": "1.2.0",
686
793
  "@types/react": "^19.0.0",
687
794
  "@types/slug": "^5.0.9",
795
+ "@vercel/blob": "^0.27.3",
688
796
  "@workspace/ui": "workspace:*",
689
797
  "ai": "^5.0.94",
690
798
  "better-call": "catalog:",
799
+ "knip": "^5.61.2",
691
800
  "react": "^19.1.1",
692
801
  "react-dom": "^19.1.1",
693
802
  "react-error-boundary": "^4.1.2",
694
- "knip": "^5.61.2",
695
803
  "rollup-plugin-preserve-directives": "0.4.0",
696
804
  "rollup-plugin-visualizer": "^5.12.0",
805
+ "tsx": "catalog:",
697
806
  "typescript": "catalog:",
698
807
  "unbuild": "catalog:",
699
808
  "vitest": "catalog:",
@@ -28,13 +28,44 @@ export function FeaturedImageField({
28
28
  const fileInputRef = useRef<HTMLInputElement>(null);
29
29
  const [isUploading, setIsUploading] = useState(false);
30
30
 
31
- const { uploadImage, Image, localization } = usePluginOverrides<
32
- BlogPluginOverrides,
33
- Partial<BlogPluginOverrides>
34
- >("blog", { localization: BLOG_LOCALIZATION });
31
+ const {
32
+ uploadImage,
33
+ Image,
34
+ localization,
35
+ imageInputField: ImageInput,
36
+ } = usePluginOverrides<BlogPluginOverrides, Partial<BlogPluginOverrides>>(
37
+ "blog",
38
+ { localization: BLOG_LOCALIZATION },
39
+ );
35
40
 
36
41
  const ImageComponent = Image ? Image : DefaultImage;
37
42
 
43
+ // When a custom imageInput component is provided via overrides, delegate to it.
44
+ if (ImageInput) {
45
+ return (
46
+ <FormItem className="flex flex-col">
47
+ <FormLabel>
48
+ {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}
49
+ {isRequired && (
50
+ <span className="text-destructive">
51
+ {" "}
52
+ {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}
53
+ </span>
54
+ )}
55
+ </FormLabel>
56
+ <FormControl>
57
+ <ImageInput
58
+ value={value || ""}
59
+ onChange={onChange}
60
+ isRequired={isRequired}
61
+ />
62
+ </FormControl>
63
+ <FormDescription />
64
+ <FormMessage />
65
+ </FormItem>
66
+ );
67
+ }
68
+
38
69
  const handleImageUpload = async (
39
70
  event: React.ChangeEvent<HTMLInputElement>,
40
71
  ) => {
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { useCallback, useRef } from "react";
2
3
  import { usePluginOverrides } from "@btst/stack/context";
3
4
  import type { BlogPluginOverrides } from "../../overrides";
4
5
  import { BLOG_LOCALIZATION } from "../../localization";
@@ -6,24 +7,78 @@ import { MarkdownEditor, type MarkdownEditorProps } from "./markdown-editor";
6
7
 
7
8
  type MarkdownEditorWithOverridesProps = Omit<
8
9
  MarkdownEditorProps,
9
- "uploadImage" | "placeholder"
10
+ | "uploadImage"
11
+ | "placeholder"
12
+ | "insertImageRef"
13
+ | "openMediaPickerForImageBlock"
10
14
  >;
11
15
 
12
16
  export function MarkdownEditorWithOverrides(
13
17
  props: MarkdownEditorWithOverridesProps,
14
18
  ) {
15
- const { uploadImage, localization } = usePluginOverrides<
16
- BlogPluginOverrides,
17
- Partial<BlogPluginOverrides>
18
- >("blog", {
19
- localization: BLOG_LOCALIZATION,
20
- });
19
+ const {
20
+ uploadImage,
21
+ imagePicker: ImagePickerTrigger,
22
+ localization,
23
+ } = usePluginOverrides<BlogPluginOverrides, Partial<BlogPluginOverrides>>(
24
+ "blog",
25
+ { localization: BLOG_LOCALIZATION },
26
+ );
27
+
28
+ const insertImageRef = useRef<((url: string) => void) | null>(null);
29
+ // Holds the Crepe-image-block `setUrl` callback while the picker is open.
30
+ const pendingInsertUrlRef = useRef<((url: string) => void) | null>(null);
31
+ // Ref to the trigger wrapper so we can programmatically click the picker button.
32
+ const triggerContainerRef = useRef<HTMLDivElement | null>(null);
33
+
34
+ // Single onSelect handler for ImagePickerTrigger.
35
+ // URLs returned by the media plugin are already percent-encoded at the
36
+ // source (storage adapter), so no additional encoding is applied here.
37
+ const handleSelect = useCallback((url: string) => {
38
+ if (pendingInsertUrlRef.current) {
39
+ // Crepe image block flow: set the URL into the block's link input.
40
+ pendingInsertUrlRef.current(url);
41
+ pendingInsertUrlRef.current = null;
42
+ } else {
43
+ // Normal flow: insert image at end of markdown content.
44
+ insertImageRef.current?.(url);
45
+ }
46
+ }, []);
47
+
48
+ // Called by MarkdownEditor's click interceptor when the user clicks a Crepe
49
+ // image-block upload placeholder.
50
+ const openMediaPickerForImageBlock = useCallback(
51
+ (setUrl: (url: string) => void) => {
52
+ pendingInsertUrlRef.current = setUrl;
53
+ // Programmatically click the visible picker trigger button.
54
+ const btn = triggerContainerRef.current?.querySelector(
55
+ '[data-testid="open-media-picker"]',
56
+ ) as HTMLButtonElement | null;
57
+ btn?.click();
58
+ },
59
+ [],
60
+ );
21
61
 
22
62
  return (
23
- <MarkdownEditor
24
- {...props}
25
- uploadImage={uploadImage}
26
- placeholder={localization?.BLOG_FORMS_EDITOR_PLACEHOLDER}
27
- />
63
+ <div className="flex flex-col">
64
+ <MarkdownEditor
65
+ {...props}
66
+ uploadImage={uploadImage}
67
+ placeholder={localization?.BLOG_FORMS_EDITOR_PLACEHOLDER}
68
+ insertImageRef={insertImageRef}
69
+ openMediaPickerForImageBlock={
70
+ ImagePickerTrigger ? openMediaPickerForImageBlock : undefined
71
+ }
72
+ />
73
+ {ImagePickerTrigger && (
74
+ <div
75
+ ref={triggerContainerRef}
76
+ className="flex justify-end mt-1"
77
+ data-testid="image-picker-trigger"
78
+ >
79
+ <ImagePickerTrigger onSelect={handleSelect} />
80
+ </div>
81
+ )}
82
+ </div>
28
83
  );
29
84
  }
@@ -8,7 +8,12 @@ import { editorViewCtx, parserCtx } from "@milkdown/kit/core";
8
8
  import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
9
9
  import { Slice } from "@milkdown/kit/prose/model";
10
10
  import { Selection } from "@milkdown/kit/prose/state";
11
- import { useLayoutEffect, useRef, useState } from "react";
11
+ import {
12
+ useLayoutEffect,
13
+ useRef,
14
+ useState,
15
+ type MutableRefObject,
16
+ } from "react";
12
17
 
13
18
  export interface MarkdownEditorProps {
14
19
  value?: string;
@@ -18,6 +23,19 @@ export interface MarkdownEditorProps {
18
23
  uploadImage?: (file: File) => Promise<string>;
19
24
  /** Placeholder text shown when the editor is empty. */
20
25
  placeholder?: string;
26
+ /**
27
+ * Optional ref that will be populated with an `insertImage(url)` function.
28
+ * Call `insertImageRef.current?.(url)` to programmatically insert an image.
29
+ * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).
30
+ */
31
+ insertImageRef?: MutableRefObject<((url: string) => void) | null>;
32
+ /**
33
+ * When provided, clicking the Crepe image block's upload area opens a media
34
+ * picker instead of the native file dialog. The callback receives a `setUrl`
35
+ * function — call it with the chosen URL to set it into the image block.
36
+ * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).
37
+ */
38
+ openMediaPickerForImageBlock?: (setUrl: (url: string) => void) => void;
21
39
  }
22
40
 
23
41
  export function MarkdownEditor({
@@ -26,6 +44,8 @@ export function MarkdownEditor({
26
44
  className,
27
45
  uploadImage,
28
46
  placeholder = "Write something...",
47
+ insertImageRef,
48
+ openMediaPickerForImageBlock,
29
49
  }: MarkdownEditorProps) {
30
50
  const containerRef = useRef<HTMLDivElement | null>(null);
31
51
  const crepeRef = useRef<Crepe | null>(null);
@@ -33,6 +53,9 @@ export function MarkdownEditor({
33
53
  const [isReady, setIsReady] = useState(false);
34
54
  const onChangeRef = useRef<typeof onChange>(onChange);
35
55
  const initialValueRef = useRef<string>(value ?? "");
56
+ const openMediaPickerRef = useRef<typeof openMediaPickerForImageBlock>(
57
+ openMediaPickerForImageBlock,
58
+ );
36
59
  type ThrottledFn = ((markdown: string) => void) & {
37
60
  cancel?: () => void;
38
61
  flush?: () => void;
@@ -40,12 +63,24 @@ export function MarkdownEditor({
40
63
  const throttledOnChangeRef = useRef<ThrottledFn | null>(null);
41
64
 
42
65
  onChangeRef.current = onChange;
66
+ openMediaPickerRef.current = openMediaPickerForImageBlock;
43
67
 
44
68
  useLayoutEffect(() => {
45
69
  if (crepeRef.current) return;
46
70
  const container = containerRef.current;
47
71
  if (!container) return;
48
72
 
73
+ const hasMediaPicker = !!openMediaPickerRef.current;
74
+
75
+ const imageBlockConfig: Record<string, unknown> = {};
76
+ if (uploadImage) {
77
+ imageBlockConfig.onUpload = async (file: File) => uploadImage(file);
78
+ }
79
+ if (hasMediaPicker) {
80
+ imageBlockConfig.blockUploadPlaceholderText = "Media Picker";
81
+ imageBlockConfig.inlineUploadPlaceholderText = "Media Picker";
82
+ }
83
+
49
84
  const crepe = new Crepe({
50
85
  root: container,
51
86
  defaultValue: initialValueRef.current,
@@ -53,19 +88,47 @@ export function MarkdownEditor({
53
88
  [CrepeFeature.Placeholder]: {
54
89
  text: placeholder,
55
90
  },
56
- ...(uploadImage
57
- ? {
58
- [CrepeFeature.ImageBlock]: {
59
- onUpload: async (file: File) => {
60
- const url = await uploadImage(file);
61
- return url;
62
- },
63
- },
64
- }
91
+ ...(Object.keys(imageBlockConfig).length > 0
92
+ ? { [CrepeFeature.ImageBlock]: imageBlockConfig }
65
93
  : {}),
66
94
  },
67
95
  });
68
96
 
97
+ // Intercept clicks on Crepe image-block upload placeholders so that the
98
+ // native file dialog is suppressed and the media picker is opened instead.
99
+ const interceptHandler = (e: MouseEvent) => {
100
+ if (!openMediaPickerRef.current) return;
101
+ const target = e.target as Element;
102
+ // Only intercept clicks inside the upload placeholder area.
103
+ const inPlaceholder = target.closest(".image-edit .placeholder");
104
+ if (!inPlaceholder) return;
105
+ // Let the hidden file <input> itself through (shouldn't receive clicks normally).
106
+ if ((target as HTMLElement).matches("input")) return;
107
+
108
+ e.preventDefault();
109
+ e.stopPropagation();
110
+
111
+ const imageEdit = inPlaceholder.closest(".image-edit");
112
+ const linkInput = imageEdit?.querySelector(
113
+ ".link-input-area",
114
+ ) as HTMLInputElement | null;
115
+
116
+ openMediaPickerRef.current((url: string) => {
117
+ if (!linkInput) return;
118
+ // Use the native setter so Vue's reactivity picks up the change.
119
+ const nativeSetter = Object.getOwnPropertyDescriptor(
120
+ HTMLInputElement.prototype,
121
+ "value",
122
+ )?.set;
123
+ nativeSetter?.call(linkInput, url);
124
+ linkInput.dispatchEvent(new Event("input", { bubbles: true }));
125
+ linkInput.dispatchEvent(
126
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
127
+ );
128
+ });
129
+ };
130
+ container.addEventListener("click", interceptHandler, true);
131
+
69
132
  // Prepare throttled onChange once per editor instance
70
133
  throttledOnChangeRef.current = throttle((markdown: string) => {
71
134
  if (onChangeRef.current) onChangeRef.current(markdown);
@@ -86,6 +149,7 @@ export function MarkdownEditor({
86
149
  crepeRef.current = crepe;
87
150
 
88
151
  return () => {
152
+ container.removeEventListener("click", interceptHandler, true);
89
153
  try {
90
154
  isReadyRef.current = false;
91
155
  throttledOnChangeRef.current?.cancel?.();
@@ -133,6 +197,38 @@ export function MarkdownEditor({
133
197
  });
134
198
  }, [value, isReady]);
135
199
 
200
+ // Expose insertImage via ref so the parent can insert images programmatically
201
+ useLayoutEffect(() => {
202
+ if (!insertImageRef) return;
203
+ insertImageRef.current = (url: string) => {
204
+ if (!crepeRef.current || !isReadyRef.current) return;
205
+ try {
206
+ const currentMarkdown = crepeRef.current.getMarkdown?.() ?? "";
207
+ const imageMarkdown = `\n\n![](${url})\n\n`;
208
+ const newMarkdown = currentMarkdown.trimEnd() + imageMarkdown;
209
+ crepeRef.current.editor.action((ctx) => {
210
+ const view = ctx.get(editorViewCtx);
211
+ const parser = ctx.get(parserCtx);
212
+ const doc = parser(newMarkdown);
213
+ if (!doc) return;
214
+ const state = view.state;
215
+ const tr = state.tr.replace(
216
+ 0,
217
+ state.doc.content.size,
218
+ new Slice(doc.content, 0, 0),
219
+ );
220
+ view.dispatch(tr);
221
+ });
222
+ if (onChangeRef.current) onChangeRef.current(newMarkdown);
223
+ } catch {
224
+ // Editor may not be ready yet
225
+ }
226
+ };
227
+ return () => {
228
+ if (insertImageRef) insertImageRef.current = null;
229
+ };
230
+ }, [insertImageRef]);
231
+
136
232
  return (
137
233
  <div ref={containerRef} className={cn("milkdown-custom", className)} />
138
234
  );
@@ -2,6 +2,18 @@ import type { SerializedPost } from "../types";
2
2
  import type { ComponentType, ReactNode } from "react";
3
3
  import type { BlogLocalization } from "./localization";
4
4
 
5
+ /**
6
+ * Props for the overridable blog featured image input component.
7
+ */
8
+ export interface BlogImageInputFieldProps {
9
+ /** Current image URL value */
10
+ value: string;
11
+ /** Called when the image URL changes */
12
+ onChange: (value: string) => void;
13
+ /** Whether the field is required */
14
+ isRequired?: boolean;
15
+ }
16
+
5
17
  /**
6
18
  * Context passed to lifecycle hooks
7
19
  */
@@ -48,9 +60,54 @@ export interface BlogPluginOverrides {
48
60
  React.ImgHTMLAttributes<HTMLImageElement> & Record<string, any>
49
61
  >;
50
62
  /**
51
- * Function used to upload an image and return its URL.
63
+ * Function used to upload a new image file and return its URL.
64
+ * This is separate from `imagePicker`, which selects an existing asset URL.
52
65
  */
53
66
  uploadImage: (file: File) => Promise<string>;
67
+ /**
68
+ * Optional custom component for the featured image field.
69
+ *
70
+ * When provided it replaces the default file-upload input entirely.
71
+ * The component receives `value` (current URL string) and `onChange` (setter).
72
+ *
73
+ * Typical use case: render a preview when a value is set, and a media-picker
74
+ * trigger when no value is set.
75
+ *
76
+ * @example
77
+ * ```tsx
78
+ * imageInputField: ({ value, onChange }) =>
79
+ * value ? (
80
+ * <div>
81
+ * <img src={value} alt="Preview" />
82
+ * <MediaPicker trigger={<button>Change</button>} accept={["image/*"]}
83
+ * onSelect={(assets) => onChange(assets[0].url)} />
84
+ * </div>
85
+ * ) : (
86
+ * <MediaPicker trigger={<button>Browse media</button>} accept={["image/*"]}
87
+ * onSelect={(assets) => onChange(assets[0].url)} />
88
+ * )
89
+ * ```
90
+ */
91
+ imageInputField?: ComponentType<BlogImageInputFieldProps>;
92
+
93
+ /**
94
+ * Optional trigger component for a media picker.
95
+ * When provided, it is rendered adjacent to the Markdown editor and allows
96
+ * users to browse and select previously uploaded assets.
97
+ * Receives `onSelect(url)` — insert the chosen URL into the editor.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * imagePicker: ({ onSelect }) => (
102
+ * <MediaPicker
103
+ * trigger={<Button size="sm" variant="outline">Browse media</Button>}
104
+ * accept={["image/*"]}
105
+ * onSelect={(assets) => onSelect(assets[0].url)}
106
+ * />
107
+ * )
108
+ * ```
109
+ */
110
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
54
111
  /**
55
112
  * Localization object for the blog plugin
56
113
  */
@@ -56,6 +56,12 @@ function buildFieldConfigFromJsonSchema(
56
56
  string,
57
57
  React.ComponentType<AutoFormInputComponentProps>
58
58
  >,
59
+ imagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,
60
+ imageInputField?: React.ComponentType<{
61
+ value: string;
62
+ onChange: (value: string) => void;
63
+ isRequired?: boolean;
64
+ }>,
59
65
  ): FieldConfig<Record<string, unknown>> {
60
66
  // Get base config from shared utility (handles fieldType from JSON Schema)
61
67
  const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
@@ -73,14 +79,14 @@ function buildFieldConfigFromJsonSchema(
73
79
  // Handle "file" fieldType when there's NO custom component for "file"
74
80
  if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
75
81
  // Use CMSFileUpload as the default file component
76
- if (!uploadImage) {
77
- // Show a clear error message if uploadImage is not provided
82
+ if (!uploadImage && !imageInputField) {
83
+ // Show a clear error message if neither uploadImage nor imageInputField is provided
78
84
  baseConfig[key] = {
79
85
  ...baseConfig[key],
80
86
  fieldType: () => (
81
87
  <div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
82
- File upload requires an <code>uploadImage</code> function in CMS
83
- overrides.
88
+ File upload requires an <code>uploadImage</code> or{" "}
89
+ <code>imageInputField</code> function in CMS overrides.
84
90
  </div>
85
91
  ),
86
92
  };
@@ -88,7 +94,12 @@ function buildFieldConfigFromJsonSchema(
88
94
  baseConfig[key] = {
89
95
  ...baseConfig[key],
90
96
  fieldType: (props: AutoFormInputComponentProps) => (
91
- <CMSFileUpload {...props} uploadImage={uploadImage} />
97
+ <CMSFileUpload
98
+ {...props}
99
+ uploadImage={uploadImage ?? (() => Promise.resolve(""))}
100
+ imageInputField={imageInputField}
101
+ imagePicker={imagePicker}
102
+ />
92
103
  ),
93
104
  };
94
105
  }
@@ -151,6 +162,8 @@ export function ContentForm({
151
162
  const {
152
163
  localization: customLocalization,
153
164
  uploadImage,
165
+ imagePicker,
166
+ imageInputField,
154
167
  fieldComponents,
155
168
  } = usePluginOverrides<CMSPluginOverrides>("cms");
156
169
  const localization = { ...CMS_LOCALIZATION, ...customLocalization };
@@ -214,8 +227,14 @@ export function ContentForm({
214
227
  // Build field config for AutoForm (fieldType is now embedded in jsonSchema)
215
228
  const fieldConfig = useMemo(
216
229
  () =>
217
- buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents),
218
- [jsonSchema, uploadImage, fieldComponents],
230
+ buildFieldConfigFromJsonSchema(
231
+ jsonSchema,
232
+ uploadImage,
233
+ fieldComponents,
234
+ imagePicker,
235
+ imageInputField,
236
+ ),
237
+ [jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],
219
238
  );
220
239
 
221
240
  // Find the field to use for slug auto-generation