@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.
- package/README.md +133 -0
- package/dist/cli.d.ts +1 -9
- package/dist/cli.js +13484 -354
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +24 -260
- package/dist/index.js +4 -11373
- package/dist/index.js.map +1 -1
- package/package.json +29 -42
- package/templates/schema.json +959 -0
- package/templates/tiptap/hooks/use-composed-ref.ts +43 -0
- package/templates/tiptap/hooks/use-cursor-visibility.ts +68 -0
- package/templates/tiptap/hooks/use-element-rect.ts +166 -0
- package/templates/tiptap/hooks/use-is-breakpoint.ts +32 -0
- package/templates/tiptap/hooks/use-menu-navigation.ts +182 -0
- package/templates/tiptap/hooks/use-scrolling.ts +64 -0
- package/templates/tiptap/hooks/use-throttled-callback.ts +146 -0
- package/templates/tiptap/hooks/use-tiptap-editor.ts +46 -0
- package/templates/tiptap/hooks/use-unmount.ts +21 -0
- package/templates/tiptap/hooks/use-window-size.ts +87 -0
- package/templates/tiptap/lib/tiptap-utils.ts +587 -0
- package/templates/tiptap/styles/_keyframe-animations.scss +91 -0
- package/templates/tiptap/styles/_variables.scss +296 -0
- package/templates/tiptap/tiptap-extension/node-background-extension.ts +138 -0
- package/templates/tiptap/tiptap-icons/align-center-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/align-justify-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/align-left-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/align-right-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/arrow-left-icon.tsx +24 -0
- package/templates/tiptap/tiptap-icons/ban-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/blockquote-icon.tsx +44 -0
- package/templates/tiptap/tiptap-icons/bold-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/chevron-down-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/close-icon.tsx +24 -0
- package/templates/tiptap/tiptap-icons/code-block-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/code2-icon.tsx +32 -0
- package/templates/tiptap/tiptap-icons/corner-down-left-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/external-link-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/heading-five-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/heading-four-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/heading-icon.tsx +24 -0
- package/templates/tiptap/tiptap-icons/heading-one-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/heading-six-icon.tsx +30 -0
- package/templates/tiptap/tiptap-icons/heading-three-icon.tsx +36 -0
- package/templates/tiptap/tiptap-icons/heading-two-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/highlighter-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/image-plus-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/italic-icon.tsx +24 -0
- package/templates/tiptap/tiptap-icons/link-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/list-icon.tsx +56 -0
- package/templates/tiptap/tiptap-icons/list-ordered-icon.tsx +56 -0
- package/templates/tiptap/tiptap-icons/list-todo-icon.tsx +50 -0
- package/templates/tiptap/tiptap-icons/moon-star-icon.tsx +30 -0
- package/templates/tiptap/tiptap-icons/redo2-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/strike-icon.tsx +28 -0
- package/templates/tiptap/tiptap-icons/subscript-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/sun-icon.tsx +58 -0
- package/templates/tiptap/tiptap-icons/superscript-icon.tsx +38 -0
- package/templates/tiptap/tiptap-icons/trash-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/underline-icon.tsx +26 -0
- package/templates/tiptap/tiptap-icons/undo2-icon.tsx +26 -0
- package/templates/tiptap/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
- package/templates/tiptap/tiptap-node/code-block-node/code-block-node.scss +54 -0
- package/templates/tiptap/tiptap-node/heading-node/heading-node.scss +45 -0
- package/templates/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +10 -0
- package/templates/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
- package/templates/tiptap/tiptap-node/image-node/image-node.scss +35 -0
- package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node-extension.ts +154 -0
- package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
- package/templates/tiptap/tiptap-node/image-upload-node/image-upload-node.tsx +522 -0
- package/templates/tiptap/tiptap-node/image-upload-node/index.tsx +1 -0
- package/templates/tiptap/tiptap-node/list-node/list-node.scss +208 -0
- package/templates/tiptap/tiptap-node/paragraph-node/paragraph-node.scss +273 -0
- package/templates/tiptap/tiptap-ui/blockquote-button/blockquote-button.tsx +104 -0
- package/templates/tiptap/tiptap-ui/blockquote-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/blockquote-button/use-blockquote.ts +252 -0
- package/templates/tiptap/tiptap-ui/code-block-button/code-block-button.tsx +106 -0
- package/templates/tiptap/tiptap-ui/code-block-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/code-block-button/use-code-block.ts +261 -0
- package/templates/tiptap/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
- package/templates/tiptap/tiptap-ui/color-highlight-button/color-highlight-button.tsx +153 -0
- package/templates/tiptap/tiptap-ui/color-highlight-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/color-highlight-button/use-color-highlight.ts +345 -0
- package/templates/tiptap/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +207 -0
- package/templates/tiptap/tiptap-ui/color-highlight-popover/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui/heading-button/heading-button.tsx +107 -0
- package/templates/tiptap/tiptap-ui/heading-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/heading-button/use-heading.ts +314 -0
- package/templates/tiptap/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +131 -0
- package/templates/tiptap/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +130 -0
- package/templates/tiptap/tiptap-ui/image-upload-button/image-upload-button.tsx +114 -0
- package/templates/tiptap/tiptap-ui/image-upload-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/image-upload-button/use-image-upload.ts +192 -0
- package/templates/tiptap/tiptap-ui/link-popover/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/link-popover/link-popover.tsx +285 -0
- package/templates/tiptap/tiptap-ui/link-popover/use-link-popover.ts +286 -0
- package/templates/tiptap/tiptap-ui/list-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/list-button/list-button.tsx +108 -0
- package/templates/tiptap/tiptap-ui/list-button/use-list.ts +329 -0
- package/templates/tiptap/tiptap-ui/list-dropdown-menu/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +123 -0
- package/templates/tiptap/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +203 -0
- package/templates/tiptap/tiptap-ui/mark-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/mark-button/mark-button.tsx +107 -0
- package/templates/tiptap/tiptap-ui/mark-button/use-mark.ts +206 -0
- package/templates/tiptap/tiptap-ui/text-align-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/text-align-button/text-align-button.tsx +118 -0
- package/templates/tiptap/tiptap-ui/text-align-button/use-text-align.ts +212 -0
- package/templates/tiptap/tiptap-ui/undo-redo-button/index.tsx +2 -0
- package/templates/tiptap/tiptap-ui/undo-redo-button/undo-redo-button.tsx +105 -0
- package/templates/tiptap/tiptap-ui/undo-redo-button/use-undo-redo.ts +173 -0
- package/templates/tiptap/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
- package/templates/tiptap/tiptap-ui-primitive/badge/badge-group.scss +16 -0
- package/templates/tiptap/tiptap-ui-primitive/badge/badge.scss +99 -0
- package/templates/tiptap/tiptap-ui-primitive/badge/badge.tsx +46 -0
- package/templates/tiptap/tiptap-ui-primitive/badge/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/button/button-colors.scss +429 -0
- package/templates/tiptap/tiptap-ui-primitive/button/button-group.scss +22 -0
- package/templates/tiptap/tiptap-ui-primitive/button/button.scss +314 -0
- package/templates/tiptap/tiptap-ui-primitive/button/button.tsx +102 -0
- package/templates/tiptap/tiptap-ui-primitive/button/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/card/card.scss +77 -0
- package/templates/tiptap/tiptap-ui-primitive/card/card.tsx +59 -0
- package/templates/tiptap/tiptap-ui-primitive/card/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +63 -0
- package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +95 -0
- package/templates/tiptap/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/input/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/input/input.scss +45 -0
- package/templates/tiptap/tiptap-ui-primitive/input/input.tsx +18 -0
- package/templates/tiptap/tiptap-ui-primitive/popover/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/popover/popover.scss +63 -0
- package/templates/tiptap/tiptap-ui-primitive/popover/popover.tsx +33 -0
- package/templates/tiptap/tiptap-ui-primitive/separator/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/separator/separator.scss +23 -0
- package/templates/tiptap/tiptap-ui-primitive/separator/separator.tsx +33 -0
- package/templates/tiptap/tiptap-ui-primitive/spacer/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/spacer/spacer.tsx +21 -0
- package/templates/tiptap/tiptap-ui-primitive/toolbar/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
- package/templates/tiptap/tiptap-ui-primitive/toolbar/toolbar.tsx +113 -0
- package/templates/tiptap/tiptap-ui-primitive/tooltip/index.tsx +1 -0
- package/templates/tiptap/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
- package/templates/tiptap/tiptap-ui-primitive/tooltip/tooltip.tsx +223 -0
- package/templates/ui/accordion.tsx +52 -0
- package/templates/ui/alert-dialog.tsx +116 -0
- package/templates/ui/alert.tsx +48 -0
- package/templates/ui/aspect-ratio.tsx +7 -0
- package/templates/ui/avatar.tsx +46 -0
- package/templates/ui/badge.tsx +32 -0
- package/templates/ui/breadcrumb.tsx +98 -0
- package/templates/ui/button-group.tsx +77 -0
- package/templates/ui/button.tsx +48 -0
- package/templates/ui/calendar.tsx +176 -0
- package/templates/ui/card.tsx +54 -0
- package/templates/ui/carousel.tsx +234 -0
- package/templates/ui/chart.tsx +349 -0
- package/templates/ui/checkbox.tsx +27 -0
- package/templates/ui/collapsible.tsx +11 -0
- package/templates/ui/command.tsx +142 -0
- package/templates/ui/context-menu.tsx +188 -0
- package/templates/ui/curriculum-editor.tsx +601 -0
- package/templates/ui/date-picker.tsx +70 -0
- package/templates/ui/dialog.tsx +103 -0
- package/templates/ui/drawer.tsx +99 -0
- package/templates/ui/dropdown-menu.tsx +185 -0
- package/templates/ui/dynamic-list-field.tsx +95 -0
- package/templates/ui/empty.tsx +90 -0
- package/templates/ui/field.tsx +231 -0
- package/templates/ui/file-upload-example.tsx +113 -0
- package/templates/ui/form.tsx +172 -0
- package/templates/ui/hover-card.tsx +28 -0
- package/templates/ui/icon-picker.tsx +435 -0
- package/templates/ui/icons-data.ts +6 -0
- package/templates/ui/image-upload-field.tsx +360 -0
- package/templates/ui/input-group.tsx +160 -0
- package/templates/ui/input-otp.tsx +70 -0
- package/templates/ui/input.tsx +21 -0
- package/templates/ui/item.tsx +171 -0
- package/templates/ui/kbd.tsx +28 -0
- package/templates/ui/label.tsx +20 -0
- package/templates/ui/logo.tsx +113 -0
- package/templates/ui/markdown-editor.tsx +303 -0
- package/templates/ui/markdown-utils.ts +128 -0
- package/templates/ui/media-upload-field.tsx +255 -0
- package/templates/ui/menubar.tsx +230 -0
- package/templates/ui/navigation-menu.tsx +119 -0
- package/templates/ui/pagination.tsx +96 -0
- package/templates/ui/placeholder.tsx +25 -0
- package/templates/ui/popover.tsx +32 -0
- package/templates/ui/progress.tsx +24 -0
- package/templates/ui/radio-group.tsx +37 -0
- package/templates/ui/resizable.tsx +41 -0
- package/templates/ui/rich-text-editor.tsx +374 -0
- package/templates/ui/scroll-area.tsx +45 -0
- package/templates/ui/select.tsx +151 -0
- package/templates/ui/separator.tsx +25 -0
- package/templates/ui/sheet.tsx +120 -0
- package/templates/ui/sidebar.tsx +684 -0
- package/templates/ui/skeleton.tsx +7 -0
- package/templates/ui/slider.tsx +24 -0
- package/templates/ui/sonner.tsx +29 -0
- package/templates/ui/spinner.tsx +15 -0
- package/templates/ui/switch.tsx +28 -0
- package/templates/ui/table.tsx +93 -0
- package/templates/ui/tabs.tsx +54 -0
- package/templates/ui/textarea.tsx +20 -0
- package/templates/ui/toast.tsx +127 -0
- package/templates/ui/toggle-group.tsx +56 -0
- package/templates/ui/toggle.tsx +43 -0
- package/templates/ui/tooltip.tsx +31 -0
- package/templates/ui/use-mobile.tsx +19 -0
- package/templates/ui/video-upload-field.tsx +368 -0
- package/dist/chunk-G4KI4DVB.js +0 -179
- package/dist/chunk-G4KI4DVB.js.map +0 -1
- package/dist/chunk-NKRQYAS6.js +0 -260
- package/dist/chunk-NKRQYAS6.js.map +0 -1
- package/dist/chunk-QLVSHP7X.js +0 -235
- package/dist/chunk-QLVSHP7X.js.map +0 -1
- package/dist/chunk-WY6BC55D.js +0 -357
- package/dist/chunk-WY6BC55D.js.map +0 -1
- package/dist/config/index.d.ts +0 -93
- package/dist/config/index.js +0 -58
- package/dist/config/index.js.map +0 -1
- package/dist/core/index.d.ts +0 -415
- package/dist/core/index.js +0 -906
- package/dist/core/index.js.map +0 -1
- package/dist/import-resolver-BaZ-rzkH.d.ts +0 -123
- package/dist/logger-awLb347n.d.ts +0 -81
- package/dist/plugins/index.d.ts +0 -213
- package/dist/plugins/index.js +0 -365
- package/dist/plugins/index.js.map +0 -1
- package/dist/types-ByX_gl6y.d.ts +0 -232
- 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 = ``
|
|
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: '', 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
|
+
}
|