@betterstart/cli 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 (234) hide show
  1. package/README.md +133 -0
  2. package/dist/cli.d.ts +1 -9
  3. package/dist/cli.js +13585 -367
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts +24 -266
  6. package/dist/index.js +4 -11378
  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 +687 -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-EIH4RRIJ.js +0 -183
  215. package/dist/chunk-EIH4RRIJ.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,360 @@
1
+ 'use client'
2
+
3
+ import { useUpload } from '@cms/hooks/use-upload'
4
+ import { cn } from '@cms/utils/cn'
5
+ import { Check, Loader2, Upload, X } from 'lucide-react'
6
+ import * as React from 'react'
7
+ import { toast } from 'sonner'
8
+ import { Button } from './button'
9
+ import { Input } from './input'
10
+ import { Progress } from './progress'
11
+
12
+ function isValidUrl(url: string): boolean {
13
+ try {
14
+ new URL(url)
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ function createPreviewUrl(file: File): string {
22
+ return URL.createObjectURL(file)
23
+ }
24
+
25
+ function revokePreviewUrl(url: string): void {
26
+ if (url.startsWith('blob:')) URL.revokeObjectURL(url)
27
+ }
28
+
29
+ function useFetchImage({ maxSizeInMB }: { accept: string; maxSizeInMB: number }) {
30
+ const [isFetching, setIsFetching] = React.useState(false)
31
+
32
+ const fetchImageFromUrl = React.useCallback(
33
+ async (url: string): Promise<File | null> => {
34
+ setIsFetching(true)
35
+ try {
36
+ const response = await fetch(url)
37
+ if (!response.ok) {
38
+ toast.error('Failed to fetch image from URL')
39
+ return null
40
+ }
41
+ const blob = await response.blob()
42
+ if (blob.size > maxSizeInMB * 1024 * 1024) {
43
+ toast.error(`Image exceeds ${maxSizeInMB}MB limit`)
44
+ return null
45
+ }
46
+ const filename = url.split('/').pop() || 'image'
47
+ return new File([blob], filename, { type: blob.type })
48
+ } catch {
49
+ toast.error('Failed to fetch image from URL')
50
+ return null
51
+ } finally {
52
+ setIsFetching(false)
53
+ }
54
+ },
55
+ [maxSizeInMB]
56
+ )
57
+
58
+ return { fetchImageFromUrl, isFetching }
59
+ }
60
+
61
+ export interface ImageUploadFieldProps {
62
+ value?: string
63
+ onChange: (value: string) => void
64
+ onBlur?: () => void
65
+ disabled?: boolean
66
+ accept?: string
67
+ maxSizeInMB?: number
68
+ className?: string
69
+ label?: string
70
+ description?: string
71
+ }
72
+
73
+ interface UrlInputState {
74
+ value: string
75
+ preview: string | null
76
+ }
77
+
78
+ /**
79
+ * Image upload field component with R2 integration
80
+ * Designed for use in forms with react-hook-form
81
+ */
82
+ export function ImageUploadField({
83
+ value,
84
+ onChange,
85
+ onBlur,
86
+ disabled = false,
87
+ accept = 'image/*',
88
+ maxSizeInMB = 5,
89
+ className,
90
+ label,
91
+ description
92
+ }: ImageUploadFieldProps) {
93
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
94
+ const [previewUrl, setPreviewUrl] = React.useState<string | null>(value || null)
95
+ const [urlState, setUrlState] = React.useState<UrlInputState>({
96
+ value: '',
97
+ preview: null
98
+ })
99
+
100
+ const { fetchImageFromUrl, isFetching: isFetchingUrl } = useFetchImage({
101
+ accept,
102
+ maxSizeInMB
103
+ })
104
+
105
+ const { upload, mutation, progress } = useUpload({
106
+ accept,
107
+ maxSizeInMB,
108
+ validationConfig: {
109
+ maxFiles: 1
110
+ },
111
+ onSuccess: (result) => {
112
+ if (result.success && result.files?.[0]) {
113
+ const uploadedUrl = result.files[0].url
114
+ onChange(uploadedUrl)
115
+ setPreviewUrl(uploadedUrl)
116
+ toast.success('Image uploaded successfully')
117
+ } else {
118
+ const errorMsg = result.error || 'Upload failed'
119
+ toast.error(errorMsg)
120
+ setPreviewUrl(value || null)
121
+ }
122
+ },
123
+ onError: (error) => {
124
+ toast.error(error.message || 'Network error during upload')
125
+ setPreviewUrl(value || null)
126
+ },
127
+ prefix: 'images'
128
+ })
129
+
130
+ // Update preview when value changes externally
131
+ React.useEffect(() => {
132
+ if (value) {
133
+ setPreviewUrl(value)
134
+ }
135
+ }, [value])
136
+
137
+ // Cleanup object URLs on unmount
138
+ React.useEffect(() => {
139
+ return () => {
140
+ if (previewUrl) {
141
+ revokePreviewUrl(previewUrl)
142
+ }
143
+ }
144
+ }, [previewUrl])
145
+
146
+ const handleFileSelect = React.useCallback(
147
+ (e: React.ChangeEvent<HTMLInputElement>) => {
148
+ const file = e.target.files?.[0]
149
+ if (!file) return
150
+
151
+ if (file.size === 0) {
152
+ toast.error('Cannot upload empty file')
153
+ if (fileInputRef.current) {
154
+ fileInputRef.current.value = ''
155
+ }
156
+ return
157
+ }
158
+
159
+ // Create local preview
160
+ try {
161
+ const localPreview = createPreviewUrl(file)
162
+ setPreviewUrl(localPreview)
163
+ } catch (error) {
164
+ console.error('[ImageUploadField] Failed to create preview:', error)
165
+ toast.error('Failed to preview image')
166
+ return
167
+ }
168
+
169
+ upload([file])
170
+ },
171
+ [upload]
172
+ )
173
+
174
+ const handleUrlChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
175
+ const url = e.target.value.trim()
176
+ const preview = url && isValidUrl(url) ? url : null
177
+ setUrlState({ value: url, preview })
178
+ }, [])
179
+
180
+ const handleUrlConfirm = React.useCallback(async () => {
181
+ if (!urlState.value.trim()) {
182
+ toast.error('Please enter a valid image URL')
183
+ return
184
+ }
185
+
186
+ const file = await fetchImageFromUrl(urlState.value.trim())
187
+ if (!file) return
188
+
189
+ // Create local preview
190
+ try {
191
+ const localPreview = createPreviewUrl(file)
192
+ setPreviewUrl(localPreview)
193
+ setUrlState({ value: '', preview: null })
194
+ } catch (error) {
195
+ console.error('[ImageUploadField] Failed to create preview:', error)
196
+ toast.error('Failed to preview image')
197
+ return
198
+ }
199
+
200
+ upload([file])
201
+ }, [urlState.value, fetchImageFromUrl, upload])
202
+
203
+ const handleRemove = React.useCallback(() => {
204
+ if (previewUrl) {
205
+ revokePreviewUrl(previewUrl)
206
+ }
207
+ setPreviewUrl(null)
208
+ setUrlState({ value: '', preview: null })
209
+ onChange('')
210
+ mutation.reset()
211
+ if (fileInputRef.current) {
212
+ fileInputRef.current.value = ''
213
+ }
214
+ }, [previewUrl, onChange, mutation])
215
+
216
+ const handleClick = React.useCallback(() => {
217
+ fileInputRef.current?.click()
218
+ }, [])
219
+
220
+ const isUploading = mutation.isPending
221
+ const uploadProgress = progress[0]?.progress || 0
222
+ const displayPreview = urlState.preview || previewUrl
223
+
224
+ return (
225
+ <div className={cn('space-y-2', className)}>
226
+ {label && (
227
+ <label htmlFor="file-input" className="text-sm font-medium">
228
+ {label}
229
+ </label>
230
+ )}
231
+ {description && <p className="text-sm text-muted-foreground">{description}</p>}
232
+
233
+ <div className="space-y-4">
234
+ <input
235
+ id="file-input"
236
+ ref={fileInputRef}
237
+ type="file"
238
+ accept={accept}
239
+ onChange={handleFileSelect}
240
+ onBlur={onBlur}
241
+ disabled={disabled || isUploading || isFetchingUrl}
242
+ className="hidden"
243
+ />
244
+
245
+ {/* URL Input Section */}
246
+ <div className="space-y-2">
247
+ <div className="flex gap-2">
248
+ <Input
249
+ type="url"
250
+ placeholder="Paste image URL here or Click below to upload"
251
+ value={urlState.value}
252
+ onChange={handleUrlChange}
253
+ onBlur={onBlur}
254
+ disabled={disabled || isUploading || isFetchingUrl}
255
+ className="flex-1"
256
+ />
257
+ {urlState.preview && (
258
+ <Button
259
+ type="button"
260
+ onClick={handleUrlConfirm}
261
+ disabled={disabled || isUploading || isFetchingUrl}
262
+ className="shrink-0"
263
+ size="lg"
264
+ >
265
+ {isFetchingUrl ? (
266
+ <Loader2 className="size-4 animate-spin" />
267
+ ) : (
268
+ <>
269
+ <Check className="size-4" />
270
+ Confirm
271
+ </>
272
+ )}
273
+ </Button>
274
+ )}
275
+ </div>
276
+ </div>
277
+
278
+ {/* Preview or Upload Area */}
279
+ {displayPreview ? (
280
+ <div className="relative w-full rounded-lg corner-squircle border border-dashed border-border bg-secondary h-50 flex items-center justify-center p-10 group">
281
+ <img
282
+ src={displayPreview}
283
+ alt="Preview"
284
+ className="w-full h-auto max-h-40 object-contain"
285
+ />
286
+
287
+ {/* Upload Progress Overlay */}
288
+ {isUploading && (
289
+ <div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center gap-2">
290
+ <Loader2 className="size-8 text-white animate-spin" />
291
+ <Progress value={uploadProgress} className="w-3/4" />
292
+ <span className="text-white text-sm">{uploadProgress}%</span>
293
+ </div>
294
+ )}
295
+
296
+ {/* Remove Button */}
297
+ {!isUploading && !isFetchingUrl && (
298
+ <Button
299
+ type="button"
300
+ variant="destructive"
301
+ size="icon"
302
+ className="absolute top-2 right-2 hidden group-hover:flex"
303
+ onClick={handleRemove}
304
+ disabled={disabled}
305
+ >
306
+ <X className="size-4" />
307
+ </Button>
308
+ )}
309
+
310
+ {/* URL Preview Indicator */}
311
+ {urlState.preview && !previewUrl && (
312
+ <div className="absolute bottom-2 left-2 bg-primary/90 text-primary-foreground text-xs px-2 py-1 rounded">
313
+ Click Confirm to upload
314
+ </div>
315
+ )}
316
+ </div>
317
+ ) : (
318
+ <button
319
+ type="button"
320
+ onClick={handleClick}
321
+ disabled={disabled || isUploading}
322
+ className={cn(
323
+ 'w-full rounded-lg corner-squircle border border-dashed border-border bg-secondary h-50 flex items-center justify-center p-10 group',
324
+ 'hover:border-primary/50 transition-colors',
325
+ 'flex flex-col items-center justify-center gap-2 p-8',
326
+ 'disabled:opacity-50 disabled:cursor-not-allowed'
327
+ )}
328
+ >
329
+ {isUploading ? (
330
+ <>
331
+ <Loader2 className="size-10 text-muted-foreground animate-spin" />
332
+ <Progress value={uploadProgress} className="w-3/4" />
333
+ <span className="text-sm text-muted-foreground">{uploadProgress}%</span>
334
+ </>
335
+ ) : (
336
+ <>
337
+ <div className="rounded-full corner-squircle bg-primary/10 p-3">
338
+ <Upload className="size-6 text-primary" />
339
+ </div>
340
+ <div className="text-center">
341
+ <p className="text-sm font-medium">Click to upload image</p>
342
+ <p className="text-xs text-muted-foreground mt-1">Max size: {maxSizeInMB}MB</p>
343
+ </div>
344
+ </>
345
+ )}
346
+ </button>
347
+ )}
348
+
349
+ {/* Error Message */}
350
+ {mutation.isError && (
351
+ <div className="text-sm text-destructive">Upload failed: {mutation.error.message}</div>
352
+ )}
353
+
354
+ {mutation.data && !mutation.data.success && (
355
+ <div className="text-sm text-destructive">{mutation.data.error}</div>
356
+ )}
357
+ </div>
358
+ </div>
359
+ )
360
+ }
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@cms/utils/cn'
4
+ import { cva, type VariantProps } from 'class-variance-authority'
5
+ import type * as React from 'react'
6
+ import { Button } from './button'
7
+ import { Input } from './input'
8
+ import { Textarea } from './textarea'
9
+
10
+ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
11
+ return (
12
+ <div
13
+ data-slot="input-group"
14
+ role="group"
15
+ className={cn(
16
+ 'group/input-group border-input-border dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]',
17
+ 'h-9 has-[>textarea]:h-auto',
18
+
19
+ // Variants based on alignment.
20
+ 'has-[>[data-align=inline-start]]:[&>input]:pl-2',
21
+ 'has-[>[data-align=inline-end]]:[&>input]:pr-2',
22
+ 'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
23
+ 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
24
+
25
+ // Focus state.
26
+ 'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
27
+
28
+ // Error state.
29
+ 'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
30
+
31
+ className
32
+ )}
33
+ {...props}
34
+ />
35
+ )
36
+ }
37
+
38
+ const inputGroupAddonVariants = cva(
39
+ "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
40
+ {
41
+ variants: {
42
+ align: {
43
+ 'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
44
+ 'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
45
+ 'block-start':
46
+ '[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5',
47
+ 'block-end':
48
+ '[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5'
49
+ }
50
+ },
51
+ defaultVariants: {
52
+ align: 'inline-start'
53
+ }
54
+ }
55
+ )
56
+
57
+ function InputGroupAddon({
58
+ className,
59
+ align = 'inline-start',
60
+ onClick,
61
+ ...props
62
+ }: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
63
+ return (
64
+ // biome-ignore lint/a11y/noStaticElementInteractions: Click-to-focus is a mouse convenience, keyboard users can tab directly to input
65
+ // biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard users can tab directly to input field
66
+ <div
67
+ data-slot="input-group-addon"
68
+ data-align={align}
69
+ className={cn(inputGroupAddonVariants({ align }), className)}
70
+ onClick={(e) => {
71
+ // Focus input when clicking addon area (but not buttons inside)
72
+ if (!(e.target as HTMLElement).closest('button')) {
73
+ e.currentTarget.parentElement?.querySelector('input')?.focus()
74
+ }
75
+ onClick?.(e)
76
+ }}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
83
+ variants: {
84
+ size: {
85
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
86
+ sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
87
+ 'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
88
+ 'icon-sm': 'size-8 p-0 has-[>svg]:p-0'
89
+ }
90
+ },
91
+ defaultVariants: {
92
+ size: 'xs'
93
+ }
94
+ })
95
+
96
+ function InputGroupButton({
97
+ className,
98
+ type = 'button',
99
+ variant = 'ghost',
100
+ size = 'xs',
101
+ ...props
102
+ }: Omit<React.ComponentProps<typeof Button>, 'size'> &
103
+ VariantProps<typeof inputGroupButtonVariants>) {
104
+ return (
105
+ <Button
106
+ type={type}
107
+ data-size={size}
108
+ variant={variant}
109
+ className={cn(inputGroupButtonVariants({ size }), className)}
110
+ {...props}
111
+ />
112
+ )
113
+ }
114
+
115
+ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
116
+ return (
117
+ <span
118
+ className={cn(
119
+ "text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
120
+ className
121
+ )}
122
+ {...props}
123
+ />
124
+ )
125
+ }
126
+
127
+ function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
128
+ return (
129
+ <Input
130
+ data-slot="input-group-control"
131
+ className={cn(
132
+ 'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
133
+ className
134
+ )}
135
+ {...props}
136
+ />
137
+ )
138
+ }
139
+
140
+ function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
141
+ return (
142
+ <Textarea
143
+ data-slot="input-group-control"
144
+ className={cn(
145
+ 'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
146
+ className
147
+ )}
148
+ {...props}
149
+ />
150
+ )
151
+ }
152
+
153
+ export {
154
+ InputGroup,
155
+ InputGroupAddon,
156
+ InputGroupButton,
157
+ InputGroupText,
158
+ InputGroupInput,
159
+ InputGroupTextarea
160
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@cms/utils/cn'
4
+ import { OTPInput, OTPInputContext } from 'input-otp'
5
+ import { Minus } from 'lucide-react'
6
+ import * as React from 'react'
7
+
8
+ const InputOTP = React.forwardRef<
9
+ React.ElementRef<typeof OTPInput>,
10
+ React.ComponentPropsWithoutRef<typeof OTPInput>
11
+ >(({ className, containerClassName, ...props }, ref) => (
12
+ <OTPInput
13
+ ref={ref}
14
+ containerClassName={cn(
15
+ 'flex items-center gap-2 has-[:disabled]:opacity-50',
16
+ containerClassName
17
+ )}
18
+ className={cn('disabled:cursor-not-allowed', className)}
19
+ {...props}
20
+ />
21
+ ))
22
+ InputOTP.displayName = 'InputOTP'
23
+
24
+ const InputOTPGroup = React.forwardRef<
25
+ React.ElementRef<'div'>,
26
+ React.ComponentPropsWithoutRef<'div'>
27
+ >(({ className, ...props }, ref) => (
28
+ <div ref={ref} className={cn('flex items-center', className)} {...props} />
29
+ ))
30
+ InputOTPGroup.displayName = 'InputOTPGroup'
31
+
32
+ const InputOTPSlot = React.forwardRef<
33
+ React.ElementRef<'div'>,
34
+ React.ComponentPropsWithoutRef<'div'> & { index: number }
35
+ >(({ index, className, ...props }, ref) => {
36
+ const inputOTPContext = React.useContext(OTPInputContext)
37
+ const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
38
+
39
+ return (
40
+ <div
41
+ ref={ref}
42
+ className={cn(
43
+ 'relative flex h-9 w-9 items-center justify-center border-y border-r border-input-border text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
44
+ isActive && 'z-10 ring-1 ring-ring',
45
+ className
46
+ )}
47
+ {...props}
48
+ >
49
+ {char}
50
+ {hasFakeCaret && (
51
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
52
+ <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
53
+ </div>
54
+ )}
55
+ </div>
56
+ )
57
+ })
58
+ InputOTPSlot.displayName = 'InputOTPSlot'
59
+
60
+ const InputOTPSeparator = React.forwardRef<
61
+ React.ElementRef<'div'>,
62
+ React.ComponentPropsWithoutRef<'div'>
63
+ >(({ ...props }, ref) => (
64
+ <div ref={ref} role="presentation" aria-hidden="true" {...props}>
65
+ <Minus />
66
+ </div>
67
+ ))
68
+ InputOTPSeparator.displayName = 'InputOTPSeparator'
69
+
70
+ export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
@@ -0,0 +1,21 @@
1
+ import { cn } from '@cms/utils/cn'
2
+ import * as React from 'react'
3
+
4
+ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
5
+ ({ className, type, ...props }, ref) => {
6
+ return (
7
+ <input
8
+ type={type}
9
+ className={cn(
10
+ 'flex h-9 w-full rounded-md border border-input-border-border bg-input px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
11
+ className
12
+ )}
13
+ ref={ref}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+ )
19
+ Input.displayName = 'Input'
20
+
21
+ export { Input }