@betterstart/cli 0.1.2 → 0.1.4

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 (234) hide show
  1. package/README.md +133 -0
  2. package/dist/cli.d.ts +1 -9
  3. package/dist/cli.js +13484 -354
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts +24 -260
  6. package/dist/index.js +4 -11373
  7. package/dist/index.js.map +1 -1
  8. package/package.json +29 -42
  9. package/templates/schema.json +959 -0
  10. package/templates/tiptap/hooks/use-composed-ref.ts +43 -0
  11. package/templates/tiptap/hooks/use-cursor-visibility.ts +68 -0
  12. package/templates/tiptap/hooks/use-element-rect.ts +166 -0
  13. package/templates/tiptap/hooks/use-is-breakpoint.ts +32 -0
  14. package/templates/tiptap/hooks/use-menu-navigation.ts +182 -0
  15. package/templates/tiptap/hooks/use-scrolling.ts +64 -0
  16. package/templates/tiptap/hooks/use-throttled-callback.ts +146 -0
  17. package/templates/tiptap/hooks/use-tiptap-editor.ts +46 -0
  18. package/templates/tiptap/hooks/use-unmount.ts +21 -0
  19. package/templates/tiptap/hooks/use-window-size.ts +87 -0
  20. package/templates/tiptap/lib/tiptap-utils.ts +587 -0
  21. package/templates/tiptap/styles/_keyframe-animations.scss +91 -0
  22. package/templates/tiptap/styles/_variables.scss +296 -0
  23. package/templates/tiptap/tiptap-extension/node-background-extension.ts +138 -0
  24. package/templates/tiptap/tiptap-icons/align-center-icon.tsx +38 -0
  25. package/templates/tiptap/tiptap-icons/align-justify-icon.tsx +38 -0
  26. package/templates/tiptap/tiptap-icons/align-left-icon.tsx +38 -0
  27. package/templates/tiptap/tiptap-icons/align-right-icon.tsx +38 -0
  28. package/templates/tiptap/tiptap-icons/arrow-left-icon.tsx +24 -0
  29. package/templates/tiptap/tiptap-icons/ban-icon.tsx +26 -0
  30. package/templates/tiptap/tiptap-icons/blockquote-icon.tsx +44 -0
  31. package/templates/tiptap/tiptap-icons/bold-icon.tsx +26 -0
  32. package/templates/tiptap/tiptap-icons/chevron-down-icon.tsx +26 -0
  33. package/templates/tiptap/tiptap-icons/close-icon.tsx +24 -0
  34. package/templates/tiptap/tiptap-icons/code-block-icon.tsx +38 -0
  35. package/templates/tiptap/tiptap-icons/code2-icon.tsx +32 -0
  36. package/templates/tiptap/tiptap-icons/corner-down-left-icon.tsx +26 -0
  37. package/templates/tiptap/tiptap-icons/external-link-icon.tsx +28 -0
  38. package/templates/tiptap/tiptap-icons/heading-five-icon.tsx +28 -0
  39. package/templates/tiptap/tiptap-icons/heading-four-icon.tsx +28 -0
  40. package/templates/tiptap/tiptap-icons/heading-icon.tsx +24 -0
  41. package/templates/tiptap/tiptap-icons/heading-one-icon.tsx +28 -0
  42. package/templates/tiptap/tiptap-icons/heading-six-icon.tsx +30 -0
  43. package/templates/tiptap/tiptap-icons/heading-three-icon.tsx +36 -0
  44. package/templates/tiptap/tiptap-icons/heading-two-icon.tsx +28 -0
  45. package/templates/tiptap/tiptap-icons/highlighter-icon.tsx +26 -0
  46. package/templates/tiptap/tiptap-icons/image-plus-icon.tsx +26 -0
  47. package/templates/tiptap/tiptap-icons/italic-icon.tsx +24 -0
  48. package/templates/tiptap/tiptap-icons/link-icon.tsx +28 -0
  49. package/templates/tiptap/tiptap-icons/list-icon.tsx +56 -0
  50. package/templates/tiptap/tiptap-icons/list-ordered-icon.tsx +56 -0
  51. package/templates/tiptap/tiptap-icons/list-todo-icon.tsx +50 -0
  52. package/templates/tiptap/tiptap-icons/moon-star-icon.tsx +30 -0
  53. package/templates/tiptap/tiptap-icons/redo2-icon.tsx +26 -0
  54. package/templates/tiptap/tiptap-icons/strike-icon.tsx +28 -0
  55. package/templates/tiptap/tiptap-icons/subscript-icon.tsx +38 -0
  56. package/templates/tiptap/tiptap-icons/sun-icon.tsx +58 -0
  57. package/templates/tiptap/tiptap-icons/superscript-icon.tsx +38 -0
  58. package/templates/tiptap/tiptap-icons/trash-icon.tsx +26 -0
  59. package/templates/tiptap/tiptap-icons/underline-icon.tsx +26 -0
  60. package/templates/tiptap/tiptap-icons/undo2-icon.tsx +26 -0
  61. package/templates/tiptap/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
  62. package/templates/tiptap/tiptap-node/code-block-node/code-block-node.scss +54 -0
  63. package/templates/tiptap/tiptap-node/heading-node/heading-node.scss +45 -0
  64. package/templates/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +10 -0
  65. package/templates/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
  66. package/templates/tiptap/tiptap-node/image-node/image-node.scss +35 -0
  67. package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node-extension.ts +154 -0
  68. package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
  69. package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node.tsx +522 -0
  70. package/templates/tiptap/tiptap-node/image-upload-node/index.tsx +1 -0
  71. package/templates/tiptap/tiptap-node/list-node/list-node.scss +208 -0
  72. package/templates/tiptap/tiptap-node/paragraph-node/paragraph-node.scss +273 -0
  73. package/templates/tiptap/tiptap-ui/blockquote-button/blockquote-button.tsx +104 -0
  74. package/templates/tiptap/tiptap-ui/blockquote-button/index.tsx +2 -0
  75. package/templates/tiptap/tiptap-ui/blockquote-button/use-blockquote.ts +252 -0
  76. package/templates/tiptap/tiptap-ui/code-block-button/code-block-button.tsx +106 -0
  77. package/templates/tiptap/tiptap-ui/code-block-button/index.tsx +2 -0
  78. package/templates/tiptap/tiptap-ui/code-block-button/use-code-block.ts +261 -0
  79. package/templates/tiptap/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
  80. package/templates/tiptap/tiptap-ui/color-highlight-button/color-highlight-button.tsx +153 -0
  81. package/templates/tiptap/tiptap-ui/color-highlight-button/index.tsx +2 -0
  82. package/templates/tiptap/tiptap-ui/color-highlight-button/use-color-highlight.ts +345 -0
  83. package/templates/tiptap/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +207 -0
  84. package/templates/tiptap/tiptap-ui/color-highlight-popover/index.tsx +1 -0
  85. package/templates/tiptap/tiptap-ui/heading-button/heading-button.tsx +107 -0
  86. package/templates/tiptap/tiptap-ui/heading-button/index.tsx +2 -0
  87. package/templates/tiptap/tiptap-ui/heading-button/use-heading.ts +314 -0
  88. package/templates/tiptap/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +131 -0
  89. package/templates/tiptap/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
  90. package/templates/tiptap/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +130 -0
  91. package/templates/tiptap/tiptap-ui/image-upload-button/image-upload-button.tsx +114 -0
  92. package/templates/tiptap/tiptap-ui/image-upload-button/index.tsx +2 -0
  93. package/templates/tiptap/tiptap-ui/image-upload-button/use-image-upload.ts +192 -0
  94. package/templates/tiptap/tiptap-ui/link-popover/index.tsx +2 -0
  95. package/templates/tiptap/tiptap-ui/link-popover/link-popover.tsx +285 -0
  96. package/templates/tiptap/tiptap-ui/link-popover/use-link-popover.ts +286 -0
  97. package/templates/tiptap/tiptap-ui/list-button/index.tsx +2 -0
  98. package/templates/tiptap/tiptap-ui/list-button/list-button.tsx +108 -0
  99. package/templates/tiptap/tiptap-ui/list-button/use-list.ts +329 -0
  100. package/templates/tiptap/tiptap-ui/list-dropdown-menu/index.tsx +1 -0
  101. package/templates/tiptap/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +123 -0
  102. package/templates/tiptap/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +203 -0
  103. package/templates/tiptap/tiptap-ui/mark-button/index.tsx +2 -0
  104. package/templates/tiptap/tiptap-ui/mark-button/mark-button.tsx +107 -0
  105. package/templates/tiptap/tiptap-ui/mark-button/use-mark.ts +206 -0
  106. package/templates/tiptap/tiptap-ui/text-align-button/index.tsx +2 -0
  107. package/templates/tiptap/tiptap-ui/text-align-button/text-align-button.tsx +118 -0
  108. package/templates/tiptap/tiptap-ui/text-align-button/use-text-align.ts +212 -0
  109. package/templates/tiptap/tiptap-ui/undo-redo-button/index.tsx +2 -0
  110. package/templates/tiptap/tiptap-ui/undo-redo-button/undo-redo-button.tsx +105 -0
  111. package/templates/tiptap/tiptap-ui/undo-redo-button/use-undo-redo.ts +173 -0
  112. package/templates/tiptap/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
  113. package/templates/tiptap/tiptap-ui-primitive/badge/badge-group.scss +16 -0
  114. package/templates/tiptap/tiptap-ui-primitive/badge/badge.scss +99 -0
  115. package/templates/tiptap/tiptap-ui-primitive/badge/badge.tsx +46 -0
  116. package/templates/tiptap/tiptap-ui-primitive/badge/index.tsx +1 -0
  117. package/templates/tiptap/tiptap-ui-primitive/button/button-colors.scss +429 -0
  118. package/templates/tiptap/tiptap-ui-primitive/button/button-group.scss +22 -0
  119. package/templates/tiptap/tiptap-ui-primitive/button/button.scss +314 -0
  120. package/templates/tiptap/tiptap-ui-primitive/button/button.tsx +102 -0
  121. package/templates/tiptap/tiptap-ui-primitive/button/index.tsx +1 -0
  122. package/templates/tiptap/tiptap-ui-primitive/card/card.scss +77 -0
  123. package/templates/tiptap/tiptap-ui-primitive/card/card.tsx +59 -0
  124. package/templates/tiptap/tiptap-ui-primitive/card/index.tsx +1 -0
  125. package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +63 -0
  126. package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +95 -0
  127. package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
  128. package/templates/tiptap/tiptap-ui-primitive/input/index.tsx +1 -0
  129. package/templates/tiptap/tiptap-ui-primitive/input/input.scss +45 -0
  130. package/templates/tiptap/tiptap-ui-primitive/input/input.tsx +18 -0
  131. package/templates/tiptap/tiptap-ui-primitive/popover/index.tsx +1 -0
  132. package/templates/tiptap/tiptap-ui-primitive/popover/popover.scss +63 -0
  133. package/templates/tiptap/tiptap-ui-primitive/popover/popover.tsx +33 -0
  134. package/templates/tiptap/tiptap-ui-primitive/separator/index.tsx +1 -0
  135. package/templates/tiptap/tiptap-ui-primitive/separator/separator.scss +23 -0
  136. package/templates/tiptap/tiptap-ui-primitive/separator/separator.tsx +33 -0
  137. package/templates/tiptap/tiptap-ui-primitive/spacer/index.tsx +1 -0
  138. package/templates/tiptap/tiptap-ui-primitive/spacer/spacer.tsx +21 -0
  139. package/templates/tiptap/tiptap-ui-primitive/toolbar/index.tsx +1 -0
  140. package/templates/tiptap/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
  141. package/templates/tiptap/tiptap-ui-primitive/toolbar/toolbar.tsx +113 -0
  142. package/templates/tiptap/tiptap-ui-primitive/tooltip/index.tsx +1 -0
  143. package/templates/tiptap/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
  144. package/templates/tiptap/tiptap-ui-primitive/tooltip/tooltip.tsx +223 -0
  145. package/templates/ui/accordion.tsx +52 -0
  146. package/templates/ui/alert-dialog.tsx +116 -0
  147. package/templates/ui/alert.tsx +48 -0
  148. package/templates/ui/aspect-ratio.tsx +7 -0
  149. package/templates/ui/avatar.tsx +46 -0
  150. package/templates/ui/badge.tsx +32 -0
  151. package/templates/ui/breadcrumb.tsx +98 -0
  152. package/templates/ui/button-group.tsx +77 -0
  153. package/templates/ui/button.tsx +48 -0
  154. package/templates/ui/calendar.tsx +176 -0
  155. package/templates/ui/card.tsx +54 -0
  156. package/templates/ui/carousel.tsx +234 -0
  157. package/templates/ui/chart.tsx +349 -0
  158. package/templates/ui/checkbox.tsx +27 -0
  159. package/templates/ui/collapsible.tsx +11 -0
  160. package/templates/ui/command.tsx +142 -0
  161. package/templates/ui/context-menu.tsx +188 -0
  162. package/templates/ui/curriculum-editor.tsx +601 -0
  163. package/templates/ui/date-picker.tsx +70 -0
  164. package/templates/ui/dialog.tsx +103 -0
  165. package/templates/ui/drawer.tsx +99 -0
  166. package/templates/ui/dropdown-menu.tsx +185 -0
  167. package/templates/ui/dynamic-list-field.tsx +95 -0
  168. package/templates/ui/empty.tsx +90 -0
  169. package/templates/ui/field.tsx +231 -0
  170. package/templates/ui/file-upload-example.tsx +113 -0
  171. package/templates/ui/form.tsx +172 -0
  172. package/templates/ui/hover-card.tsx +28 -0
  173. package/templates/ui/icon-picker.tsx +435 -0
  174. package/templates/ui/icons-data.ts +6 -0
  175. package/templates/ui/image-upload-field.tsx +360 -0
  176. package/templates/ui/input-group.tsx +160 -0
  177. package/templates/ui/input-otp.tsx +70 -0
  178. package/templates/ui/input.tsx +21 -0
  179. package/templates/ui/item.tsx +171 -0
  180. package/templates/ui/kbd.tsx +28 -0
  181. package/templates/ui/label.tsx +20 -0
  182. package/templates/ui/logo.tsx +113 -0
  183. package/templates/ui/markdown-editor.tsx +303 -0
  184. package/templates/ui/markdown-utils.ts +128 -0
  185. package/templates/ui/media-upload-field.tsx +255 -0
  186. package/templates/ui/menubar.tsx +230 -0
  187. package/templates/ui/navigation-menu.tsx +119 -0
  188. package/templates/ui/pagination.tsx +96 -0
  189. package/templates/ui/placeholder.tsx +25 -0
  190. package/templates/ui/popover.tsx +32 -0
  191. package/templates/ui/progress.tsx +24 -0
  192. package/templates/ui/radio-group.tsx +37 -0
  193. package/templates/ui/resizable.tsx +41 -0
  194. package/templates/ui/rich-text-editor.tsx +374 -0
  195. package/templates/ui/scroll-area.tsx +45 -0
  196. package/templates/ui/select.tsx +151 -0
  197. package/templates/ui/separator.tsx +25 -0
  198. package/templates/ui/sheet.tsx +120 -0
  199. package/templates/ui/sidebar.tsx +684 -0
  200. package/templates/ui/skeleton.tsx +7 -0
  201. package/templates/ui/slider.tsx +24 -0
  202. package/templates/ui/sonner.tsx +29 -0
  203. package/templates/ui/spinner.tsx +15 -0
  204. package/templates/ui/switch.tsx +28 -0
  205. package/templates/ui/table.tsx +93 -0
  206. package/templates/ui/tabs.tsx +54 -0
  207. package/templates/ui/textarea.tsx +20 -0
  208. package/templates/ui/toast.tsx +127 -0
  209. package/templates/ui/toggle-group.tsx +56 -0
  210. package/templates/ui/toggle.tsx +43 -0
  211. package/templates/ui/tooltip.tsx +31 -0
  212. package/templates/ui/use-mobile.tsx +19 -0
  213. package/templates/ui/video-upload-field.tsx +368 -0
  214. package/dist/chunk-G4KI4DVB.js +0 -179
  215. package/dist/chunk-G4KI4DVB.js.map +0 -1
  216. package/dist/chunk-NKRQYAS6.js +0 -260
  217. package/dist/chunk-NKRQYAS6.js.map +0 -1
  218. package/dist/chunk-QLVSHP7X.js +0 -235
  219. package/dist/chunk-QLVSHP7X.js.map +0 -1
  220. package/dist/chunk-WY6BC55D.js +0 -357
  221. package/dist/chunk-WY6BC55D.js.map +0 -1
  222. package/dist/config/index.d.ts +0 -93
  223. package/dist/config/index.js +0 -58
  224. package/dist/config/index.js.map +0 -1
  225. package/dist/core/index.d.ts +0 -415
  226. package/dist/core/index.js +0 -906
  227. package/dist/core/index.js.map +0 -1
  228. package/dist/import-resolver-BaZ-rzkH.d.ts +0 -123
  229. package/dist/logger-awLb347n.d.ts +0 -81
  230. package/dist/plugins/index.d.ts +0 -213
  231. package/dist/plugins/index.js +0 -365
  232. package/dist/plugins/index.js.map +0 -1
  233. package/dist/types-ByX_gl6y.d.ts +0 -232
  234. package/dist/types-eI549DEG.d.ts +0 -331
@@ -0,0 +1,303 @@
1
+ 'use client'
2
+
3
+ import { useTheme } from '@cms/hooks/use-cms-theme'
4
+ import { useEditorImageUpload } from '@cms/hooks/use-editor-image-upload'
5
+ import type { MarkdownEditorProps } from '@cms/types'
6
+ import { cn } from '@cms/utils/cn'
7
+ import { markdown } from '@codemirror/lang-markdown'
8
+ import { EditorView } from '@codemirror/view'
9
+ import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
10
+ import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'
11
+ import { ChevronDown, Loader2, WandSparkles } from 'lucide-react'
12
+ import * as React from 'react'
13
+ import { Button } from './button'
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuLabel,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuTrigger
21
+ } from './dropdown-menu'
22
+ import {
23
+ applyCursorChange,
24
+ formatMarkdown,
25
+ insertComponentAtCursor,
26
+ insertTextAtCursor,
27
+ toolbarButtons
28
+ } from './markdown-utils'
29
+
30
+ export function MarkdownEditor({
31
+ componentSnippets,
32
+ value = '',
33
+ onChange,
34
+ placeholder,
35
+ disabled = false,
36
+ className
37
+ }: MarkdownEditorProps) {
38
+ const editorRef = React.useRef<ReactCodeMirrorRef>(null)
39
+ const { resolvedTheme } = useTheme()
40
+ const editorTheme = resolvedTheme === 'dark' ? githubDark : githubLight
41
+
42
+ // Keep refs so image upload callback can access latest value/onChange
43
+ const valueRef = React.useRef(value)
44
+ valueRef.current = value
45
+ const onChangeRef = React.useRef(onChange)
46
+ onChangeRef.current = onChange
47
+
48
+ const {
49
+ isUploading,
50
+ progress,
51
+ uploadImages,
52
+ handleDrop,
53
+ openFilePicker,
54
+ fileInputRef,
55
+ handleFileInputChange
56
+ } = useEditorImageUpload({
57
+ onImagesUploaded: (images) => {
58
+ if (disabled) return
59
+ const view = editorRef.current?.view ?? null
60
+ const currentValue = valueRef.current || ''
61
+ let resultValue = currentValue
62
+ let cursor: number | undefined
63
+
64
+ for (const img of images) {
65
+ const markdownImage = `![${img.filename}](${img.url})`
66
+ const { newValue, newCursorPos } = insertTextAtCursor(view, resultValue, {
67
+ before: markdownImage,
68
+ after: '',
69
+ placeholder: ''
70
+ })
71
+ resultValue = newValue
72
+ cursor = newCursorPos
73
+ }
74
+
75
+ onChangeRef.current?.(resultValue)
76
+ if (view && cursor !== undefined) {
77
+ applyCursorChange(view, cursor)
78
+ }
79
+ }
80
+ })
81
+
82
+ // Stable ref so CodeMirror paste extension can access latest uploadImages
83
+ const pasteUploadRef = React.useRef(uploadImages)
84
+ pasteUploadRef.current = uploadImages
85
+
86
+ const insertText = React.useCallback(
87
+ (before: string, after: string = '', placeholder: string = '') => {
88
+ if (disabled) return
89
+
90
+ const view = editorRef.current?.view ?? null
91
+ const currentValue = value || ''
92
+
93
+ const { newValue, newCursorPos } = insertTextAtCursor(view, currentValue, {
94
+ before,
95
+ after,
96
+ placeholder
97
+ })
98
+
99
+ onChange?.(newValue)
100
+
101
+ if (view) {
102
+ applyCursorChange(view, newCursorPos)
103
+ }
104
+ },
105
+ [disabled, value, onChange]
106
+ )
107
+
108
+ const insertComponent = React.useCallback(
109
+ (snippet: string) => {
110
+ if (disabled) return
111
+
112
+ const view = editorRef.current?.view ?? null
113
+ const currentValue = value || ''
114
+
115
+ const { newValue, newCursorPos } = insertComponentAtCursor(view, currentValue, snippet)
116
+
117
+ onChange?.(newValue)
118
+
119
+ if (view) {
120
+ applyCursorChange(view, newCursorPos)
121
+ }
122
+ },
123
+ [disabled, value, onChange]
124
+ )
125
+
126
+ const formatContent = React.useCallback(() => {
127
+ if (disabled) return
128
+
129
+ const currentValue = value || ''
130
+ const formatted = formatMarkdown(currentValue)
131
+
132
+ if (formatted !== currentValue) {
133
+ onChange?.(formatted)
134
+ }
135
+ }, [disabled, value, onChange])
136
+
137
+ // Paste handler for CodeMirror - intercept image pastes
138
+ const pasteExtension = React.useMemo(
139
+ () =>
140
+ EditorView.domEventHandlers({
141
+ paste: (event) => {
142
+ const items = event.clipboardData?.items
143
+ if (!items) return false
144
+ const imageFiles: File[] = []
145
+ for (const item of items) {
146
+ if (item.type.startsWith('image/')) {
147
+ const file = item.getAsFile()
148
+ if (file) imageFiles.push(file)
149
+ }
150
+ }
151
+ if (imageFiles.length > 0) {
152
+ event.preventDefault()
153
+ pasteUploadRef.current(imageFiles)
154
+ return true
155
+ }
156
+ return false
157
+ }
158
+ }),
159
+ []
160
+ )
161
+
162
+ const overallProgress =
163
+ progress.length > 0
164
+ ? Math.round(progress.reduce((sum, p) => sum + p.progress, 0) / progress.length)
165
+ : 0
166
+
167
+ // Image toolbar button index (the Image button in toolbarButtons)
168
+ const IMAGE_BUTTON_INDEX = 9
169
+
170
+ return (
171
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target
172
+ <div
173
+ className={cn(
174
+ 'border rounded-md corner-squircle overflow-hidden flex flex-col resize-y min-h-[200px] max-h-[90vh] relative',
175
+ className
176
+ )}
177
+ onDragOver={(e) => {
178
+ e.preventDefault()
179
+ e.stopPropagation()
180
+ }}
181
+ onDrop={handleDrop}
182
+ >
183
+ <div className="flex items-center gap-1 p-2 border-b bg-muted/30 shrink-0">
184
+ <div className="flex items-center gap-0.5 flex-wrap">
185
+ {toolbarButtons.map((btn, index) => (
186
+ <Button
187
+ key={btn.label}
188
+ type="button"
189
+ variant="ghost"
190
+ size="icon"
191
+ onClick={
192
+ index === IMAGE_BUTTON_INDEX
193
+ ? openFilePicker
194
+ : () => insertText(btn.before, btn.after, btn.placeholder)
195
+ }
196
+ disabled={disabled || (index === IMAGE_BUTTON_INDEX && isUploading)}
197
+ title={btn.label}
198
+ className="size-8"
199
+ >
200
+ <btn.icon className="size-4" />
201
+ </Button>
202
+ ))}
203
+ </div>
204
+
205
+ <div className="h-6 w-px bg-border mx-1" />
206
+
207
+ <Button
208
+ type="button"
209
+ variant="ghost"
210
+ size="icon"
211
+ onClick={formatContent}
212
+ disabled={disabled}
213
+ title="Format content"
214
+ className="size-8"
215
+ >
216
+ <WandSparkles className="size-4" />
217
+ </Button>
218
+
219
+ <div className="h-6 w-px bg-border mx-1" />
220
+
221
+ <DropdownMenu>
222
+ <DropdownMenuTrigger asChild>
223
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} className="gap-1">
224
+ Components
225
+ <ChevronDown className="size-3" />
226
+ </Button>
227
+ </DropdownMenuTrigger>
228
+ <DropdownMenuContent align="start" className="w-56 max-h-96 overflow-y-auto">
229
+ {Object.entries(componentSnippets).map(([category, items]) => (
230
+ <React.Fragment key={category}>
231
+ <DropdownMenuLabel>{category}</DropdownMenuLabel>
232
+ {items.map((component) => (
233
+ <DropdownMenuItem
234
+ key={component.name}
235
+ onClick={() => insertComponent(component.snippet)}
236
+ >
237
+ {component.name}
238
+ </DropdownMenuItem>
239
+ ))}
240
+ <DropdownMenuSeparator />
241
+ </React.Fragment>
242
+ ))}
243
+ </DropdownMenuContent>
244
+ </DropdownMenu>
245
+ </div>
246
+
247
+ <div className="flex-1 min-h-0 flex flex-col">
248
+ <CodeMirror
249
+ ref={editorRef}
250
+ value={value}
251
+ onChange={onChange}
252
+ placeholder={placeholder}
253
+ extensions={[
254
+ markdown(),
255
+ EditorView.lineWrapping,
256
+ EditorView.theme({
257
+ '&': { flex: '1', minHeight: '0', overflow: 'hidden' },
258
+ '& .cm-scroller': { overflow: 'auto' }
259
+ }),
260
+ pasteExtension
261
+ ]}
262
+ theme={editorTheme}
263
+ editable={!disabled}
264
+ basicSetup={{
265
+ lineNumbers: true,
266
+ foldGutter: true,
267
+ highlightActiveLine: true,
268
+ highlightActiveLineGutter: true
269
+ }}
270
+ className={cn('text-[13px] font-medium flex-1 min-h-0 overflow-hidden flex flex-col', {
271
+ 'opacity-50 cursor-not-allowed': disabled
272
+ })}
273
+ />
274
+ </div>
275
+
276
+ {/* Upload progress overlay */}
277
+ {isUploading && (
278
+ <div className="absolute inset-0 bg-background/60 flex items-center justify-center z-10">
279
+ <div className="flex flex-col items-center gap-2">
280
+ <Loader2 className="size-6 animate-spin text-primary" />
281
+ <div className="text-sm text-muted-foreground">Uploading... {overallProgress}%</div>
282
+ <div className="w-48 h-1.5 bg-muted rounded-full overflow-hidden">
283
+ <div
284
+ className="h-full bg-primary rounded-full transition-all duration-200"
285
+ style={{ width: `${overallProgress}%` }}
286
+ />
287
+ </div>
288
+ </div>
289
+ </div>
290
+ )}
291
+
292
+ {/* Hidden file input */}
293
+ <input
294
+ ref={fileInputRef}
295
+ type="file"
296
+ multiple
297
+ accept="image/*"
298
+ className="hidden"
299
+ onChange={handleFileInputChange}
300
+ />
301
+ </div>
302
+ )
303
+ }
@@ -0,0 +1,128 @@
1
+ import type { EditorView } from '@codemirror/view'
2
+ import {
3
+ Bold,
4
+ Code,
5
+ Heading1,
6
+ Heading2,
7
+ Heading3,
8
+ Image,
9
+ Italic,
10
+ Link,
11
+ List,
12
+ ListOrdered,
13
+ type LucideIcon,
14
+ Quote,
15
+ Strikethrough
16
+ } from 'lucide-react'
17
+
18
+ export interface ToolbarButton {
19
+ label: string
20
+ icon: LucideIcon
21
+ before: string
22
+ after: string
23
+ placeholder: string
24
+ }
25
+
26
+ export const toolbarButtons: ToolbarButton[] = [
27
+ { label: 'Bold', icon: Bold, before: '**', after: '**', placeholder: 'bold text' },
28
+ { label: 'Italic', icon: Italic, before: '_', after: '_', placeholder: 'italic text' },
29
+ {
30
+ label: 'Strikethrough',
31
+ icon: Strikethrough,
32
+ before: '~~',
33
+ after: '~~',
34
+ placeholder: 'strikethrough'
35
+ },
36
+ { label: 'Heading 1', icon: Heading1, before: '# ', after: '', placeholder: 'Heading' },
37
+ { label: 'Heading 2', icon: Heading2, before: '## ', after: '', placeholder: 'Heading' },
38
+ { label: 'Heading 3', icon: Heading3, before: '### ', after: '', placeholder: 'Heading' },
39
+ { label: 'Bulleted List', icon: List, before: '- ', after: '', placeholder: 'List item' },
40
+ { label: 'Numbered List', icon: ListOrdered, before: '1. ', after: '', placeholder: 'List item' },
41
+ { label: 'Quote', icon: Quote, before: '> ', after: '', placeholder: 'Quote' },
42
+ { label: 'Image', icon: Image, before: '![', after: '](url)', placeholder: 'alt text' },
43
+ { label: 'Link', icon: Link, before: '[', after: '](url)', placeholder: 'link text' },
44
+ { label: 'Code', icon: Code, before: '`', after: '`', placeholder: 'code' }
45
+ ]
46
+
47
+ interface InsertResult {
48
+ newValue: string
49
+ newCursorPos: number
50
+ }
51
+
52
+ export function insertTextAtCursor(
53
+ view: EditorView | null,
54
+ currentValue: string,
55
+ opts: { before: string; after: string; placeholder: string }
56
+ ): InsertResult {
57
+ const { before, after, placeholder } = opts
58
+
59
+ if (view) {
60
+ const { from, to } = view.state.selection.main
61
+ const selected = view.state.sliceDoc(from, to)
62
+ const insert = selected || placeholder
63
+ const newText = `${before}${insert}${after}`
64
+
65
+ view.dispatch({
66
+ changes: { from, to, insert: newText },
67
+ selection: {
68
+ anchor: from + before.length,
69
+ head: from + before.length + insert.length
70
+ }
71
+ })
72
+
73
+ return {
74
+ newValue: currentValue.slice(0, from) + newText + currentValue.slice(to),
75
+ newCursorPos: from + before.length + insert.length
76
+ }
77
+ }
78
+
79
+ const insert = placeholder
80
+ const newText = `${before}${insert}${after}`
81
+ return {
82
+ newValue: currentValue + newText,
83
+ newCursorPos: currentValue.length + before.length + insert.length
84
+ }
85
+ }
86
+
87
+ export function insertComponentAtCursor(
88
+ view: EditorView | null,
89
+ currentValue: string,
90
+ snippet: string
91
+ ): InsertResult {
92
+ const wrapped = `\n${snippet}\n`
93
+
94
+ if (view) {
95
+ const { from } = view.state.selection.main
96
+ view.dispatch({
97
+ changes: { from, insert: wrapped },
98
+ selection: { anchor: from + wrapped.length }
99
+ })
100
+
101
+ return {
102
+ newValue: currentValue.slice(0, from) + wrapped + currentValue.slice(from),
103
+ newCursorPos: from + wrapped.length
104
+ }
105
+ }
106
+
107
+ return {
108
+ newValue: currentValue + wrapped,
109
+ newCursorPos: currentValue.length + wrapped.length
110
+ }
111
+ }
112
+
113
+ export function applyCursorChange(view: EditorView, cursorPos: number): void {
114
+ requestAnimationFrame(() => {
115
+ view.dispatch({
116
+ selection: { anchor: Math.min(cursorPos, view.state.doc.length) }
117
+ })
118
+ view.focus()
119
+ })
120
+ }
121
+
122
+ export function formatMarkdown(content: string): string {
123
+ return content
124
+ .replace(/\n{3,}/g, '\n\n') // collapse multiple blank lines
125
+ .replace(/[ \t]+$/gm, '') // trim trailing whitespace
126
+ .replace(/^\n+/, '') // trim leading newlines
127
+ .replace(/\n*$/, '\n') // ensure trailing newline
128
+ }
@@ -0,0 +1,255 @@
1
+ 'use client'
2
+
3
+ import { useUpload } from '@cms/hooks/use-upload'
4
+ import { cn } from '@cms/utils/cn'
5
+ import { Loader2, Upload, X } from 'lucide-react'
6
+ import * as React from 'react'
7
+ import { toast } from 'sonner'
8
+ import { Button } from './button'
9
+ import { Progress } from './progress'
10
+
11
+ export interface MediaUploadFieldProps {
12
+ value?: string
13
+ onChange: (value: string) => void
14
+ onBlur?: () => void
15
+ disabled?: boolean
16
+ accept?: string
17
+ maxSizeInMB?: number
18
+ className?: string
19
+ label?: string
20
+ description?: string
21
+ }
22
+
23
+ // Common image and video MIME types
24
+ const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
25
+ const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo']
26
+ const MEDIA_TYPES = [...IMAGE_TYPES, ...VIDEO_TYPES]
27
+
28
+ /**
29
+ * Detect if a URL or file is an image or video based on extension or MIME type
30
+ */
31
+ function getMediaType(urlOrMime: string): 'image' | 'video' | 'unknown' {
32
+ const lower = urlOrMime.toLowerCase()
33
+
34
+ // Check for video MIME types or extensions
35
+ if (
36
+ VIDEO_TYPES.some((type) => lower.includes(type)) ||
37
+ /\.(mp4|webm|ogg|mov|avi|mkv)($|\?)/.test(lower)
38
+ ) {
39
+ return 'video'
40
+ }
41
+
42
+ // Check for image MIME types or extensions
43
+ if (
44
+ IMAGE_TYPES.some((type) => lower.includes(type)) ||
45
+ /\.(jpg|jpeg|png|gif|webp|svg)($|\?)/.test(lower)
46
+ ) {
47
+ return 'image'
48
+ }
49
+
50
+ return 'unknown'
51
+ }
52
+
53
+ /**
54
+ * Media upload field component with R2 integration
55
+ * Supports both images and videos
56
+ * Designed for use in forms with react-hook-form
57
+ */
58
+ export function MediaUploadField({
59
+ value,
60
+ onChange,
61
+ onBlur,
62
+ disabled = false,
63
+ accept = 'image/*,video/*',
64
+ maxSizeInMB = 100,
65
+ className,
66
+ label,
67
+ description
68
+ }: MediaUploadFieldProps) {
69
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
70
+ const [previewUrl, setPreviewUrl] = React.useState<string | null>(value || null)
71
+ const [mediaType, setMediaType] = React.useState<'image' | 'video' | 'unknown'>(
72
+ value ? getMediaType(value) : 'unknown'
73
+ )
74
+
75
+ const { upload, mutation, progress } = useUpload({
76
+ validationConfig: {
77
+ maxSizeInBytes: maxSizeInMB * 1024 * 1024,
78
+ allowedTypes:
79
+ accept === 'image/*,video/*' ? MEDIA_TYPES : accept.split(',').map((t) => t.trim()),
80
+ maxFiles: 1
81
+ },
82
+ onSuccess: (result) => {
83
+ if (result.success && result.files?.[0]) {
84
+ const uploadedUrl = result.files[0].url
85
+ onChange(uploadedUrl)
86
+ setPreviewUrl(uploadedUrl)
87
+ setMediaType(getMediaType(uploadedUrl))
88
+ toast.success('Media uploaded successfully')
89
+ } else {
90
+ const errorMsg = result.error || 'Upload failed'
91
+ toast.error(errorMsg)
92
+ console.error('Upload failed:', errorMsg)
93
+ setPreviewUrl(value || null)
94
+ setMediaType(value ? getMediaType(value) : 'unknown')
95
+ }
96
+ },
97
+ onError: (error) => {
98
+ const errorMsg = error.message || 'Network error during upload'
99
+ toast.error(errorMsg)
100
+ console.error('Upload error:', error)
101
+ setPreviewUrl(value || null)
102
+ setMediaType(value ? getMediaType(value) : 'unknown')
103
+ },
104
+ prefix: 'media'
105
+ })
106
+
107
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
108
+ const file = e.target.files?.[0]
109
+ if (!file) return
110
+
111
+ if (file.size === 0) {
112
+ toast.error('Cannot upload empty file')
113
+ if (fileInputRef.current) {
114
+ fileInputRef.current.value = ''
115
+ }
116
+ return
117
+ }
118
+
119
+ try {
120
+ const localPreview = URL.createObjectURL(file)
121
+ setPreviewUrl(localPreview)
122
+ setMediaType(getMediaType(file.type))
123
+ } catch (error) {
124
+ console.error('[MediaUploadField] Failed to create preview:', error)
125
+ toast.error('Failed to preview media')
126
+ return
127
+ }
128
+
129
+ upload([file])
130
+ }
131
+
132
+ const handleRemove = () => {
133
+ setPreviewUrl(null)
134
+ setMediaType('unknown')
135
+ onChange('')
136
+ mutation.reset()
137
+ if (fileInputRef.current) {
138
+ fileInputRef.current.value = ''
139
+ }
140
+ }
141
+
142
+ const handleClick = () => {
143
+ fileInputRef.current?.click()
144
+ }
145
+
146
+ const isUploading = mutation.isPending
147
+ const uploadProgress = progress[0]?.progress || 0
148
+
149
+ return (
150
+ <div className={cn('space-y-2', className)}>
151
+ {label && (
152
+ <label htmlFor="media-input" className="text-sm font-medium">
153
+ {label}
154
+ </label>
155
+ )}
156
+ {description && <p className="text-sm text-muted-foreground">{description}</p>}
157
+
158
+ <div className="space-y-4">
159
+ <input
160
+ id="media-input"
161
+ ref={fileInputRef}
162
+ type="file"
163
+ accept={accept}
164
+ onChange={handleFileSelect}
165
+ onBlur={onBlur}
166
+ disabled={disabled || isUploading}
167
+ className="hidden"
168
+ />
169
+
170
+ {/* Preview or Upload Area */}
171
+ {previewUrl ? (
172
+ <div className="relative w-full rounded-lg corner-squircle border border-dashed border-border bg-secondary/50 group">
173
+ {mediaType === 'video' ? (
174
+ <video
175
+ src={previewUrl}
176
+ controls
177
+ className="w-full h-auto max-h-60 object-contain rounded-lg corner-squircle"
178
+ >
179
+ <track kind="captions" />
180
+ </video>
181
+ ) : (
182
+ <img
183
+ src={previewUrl}
184
+ alt="Preview"
185
+ className="w-full h-auto max-h-40 object-contain"
186
+ />
187
+ )}
188
+
189
+ {/* Upload Progress Overlay */}
190
+ {isUploading && (
191
+ <div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center gap-2 rounded-lg corner-squircle">
192
+ <Loader2 className="size-8 text-white animate-spin" />
193
+ <Progress value={uploadProgress} className="w-3/4" />
194
+ <span className="text-white text-sm">{uploadProgress}%</span>
195
+ </div>
196
+ )}
197
+
198
+ {/* Remove Button */}
199
+ {!isUploading && (
200
+ <Button
201
+ type="button"
202
+ variant="destructive"
203
+ size="icon"
204
+ className="absolute top-2 right-2 hidden group-hover:flex"
205
+ onClick={handleRemove}
206
+ disabled={disabled}
207
+ >
208
+ <X className="size-4" />
209
+ </Button>
210
+ )}
211
+ </div>
212
+ ) : (
213
+ <button
214
+ type="button"
215
+ onClick={handleClick}
216
+ disabled={disabled || isUploading}
217
+ className={cn(
218
+ 'w-full rounded-lg corner-squircle border border-dashed border-border bg-secondary/50',
219
+ 'hover:border-primary/50 transition-colors',
220
+ 'flex flex-col items-center justify-center gap-2 p-8',
221
+ 'disabled:opacity-50 disabled:cursor-not-allowed'
222
+ )}
223
+ >
224
+ {isUploading ? (
225
+ <>
226
+ <Loader2 className="size-10 text-muted-foreground animate-spin" />
227
+ <Progress value={uploadProgress} className="w-3/4" />
228
+ <span className="text-sm text-muted-foreground">{uploadProgress}%</span>
229
+ </>
230
+ ) : (
231
+ <>
232
+ <div className="rounded-full corner-squircle bg-primary/10 p-3">
233
+ <Upload className="size-6 text-primary" />
234
+ </div>
235
+ <div className="text-center">
236
+ <p className="text-sm font-medium">Click to upload image or video</p>
237
+ <p className="text-xs text-muted-foreground mt-1">Max size: {maxSizeInMB}MB</p>
238
+ </div>
239
+ </>
240
+ )}
241
+ </button>
242
+ )}
243
+
244
+ {/* Error Message */}
245
+ {mutation.isError && (
246
+ <div className="text-sm text-destructive">Upload failed: {mutation.error.message}</div>
247
+ )}
248
+
249
+ {mutation.data && !mutation.data.success && (
250
+ <div className="text-sm text-destructive">{mutation.data.error}</div>
251
+ )}
252
+ </div>
253
+ </div>
254
+ )
255
+ }