Uploader.tsx raw
1 import { isSupportedImage } from '@/lib/image-scaler'
2 import { UploadedVariant } from '@/lib/responsive-image-event'
3 import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service'
4 import { VerifiedEvent } from 'nostr-tools'
5 import { useRef } from 'react'
6 import { toast } from 'sonner'
7
8 type UploaderProps = {
9 children: React.ReactNode
10 onUploadStart?: (file: File, cancel: () => void) => void
11 onUploadEnd?: (file: File) => void
12 onProgress?: (file: File, progress: number) => void
13 className?: string
14 accept?: string
15 } & (
16 | {
17 /** Standard upload mode - returns URL and NIP-94 tags */
18 responsive?: false
19 onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
20 }
21 | {
22 /** Responsive upload mode - generates multiple variants and publishes kind 1063 event */
23 responsive: true
24 onUploadSuccess: ({
25 event,
26 variants,
27 primaryUrl
28 }: {
29 event: VerifiedEvent
30 variants: UploadedVariant[]
31 primaryUrl: string
32 }) => void
33 /** Description/caption for the image binding event */
34 description?: string
35 /** Alt text for accessibility */
36 alt?: string
37 }
38 )
39
40 export default function Uploader(props: UploaderProps) {
41 const {
42 children,
43 onUploadStart,
44 onUploadEnd,
45 onProgress,
46 className,
47 accept = 'image/*'
48 } = props
49 const fileInputRef = useRef<HTMLInputElement>(null)
50
51 const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
52 if (!event.target.files) return
53
54 const abortControllerMap = new Map<File, AbortController>()
55
56 for (const file of event.target.files) {
57 const abortController = new AbortController()
58 abortControllerMap.set(file, abortController)
59 onUploadStart?.(file, () => abortController.abort())
60 }
61
62 for (const file of event.target.files) {
63 try {
64 const abortController = abortControllerMap.get(file)
65
66 if (props.responsive && isSupportedImage(file)) {
67 // Responsive upload mode - generate variants and publish binding event
68 const result = await mediaUpload.uploadResponsiveImage(file, {
69 onProgress: (p) => onProgress?.(file, p),
70 signal: abortController?.signal,
71 description: props.description,
72 alt: props.alt
73 })
74
75 // Find the best URL to insert (prefer desktop-sm or original)
76 const primaryVariant =
77 result.variants.find((v) => v.variant === 'desktop-sm') ||
78 result.variants.find((v) => v.variant === 'original') ||
79 result.variants[result.variants.length - 1]
80
81 props.onUploadSuccess({
82 event: result.event,
83 variants: result.variants,
84 primaryUrl: primaryVariant?.url ?? result.variants[0]?.url
85 })
86 } else {
87 // Standard upload mode (or fallback for non-image files in responsive mode)
88 const result = await mediaUpload.upload(file, {
89 onProgress: (p) => onProgress?.(file, p),
90 signal: abortController?.signal
91 })
92 if (props.responsive) {
93 // In responsive mode but file is not a supported image - return as single "variant"
94 props.onUploadSuccess({
95 event: null as unknown as VerifiedEvent, // No binding event for non-images
96 variants: [],
97 primaryUrl: result.url
98 })
99 } else {
100 props.onUploadSuccess(result)
101 }
102 }
103
104 onUploadEnd?.(file)
105 } catch (error) {
106 console.error('Error uploading file', error)
107 const message = (error as Error).message
108 if (message !== UPLOAD_ABORTED_ERROR_MSG) {
109 toast.error(`Failed to upload file: ${message}`)
110 }
111 if (fileInputRef.current) {
112 fileInputRef.current.value = ''
113 }
114 onUploadEnd?.(file)
115 }
116 }
117 }
118
119 const handleUploadClick = () => {
120 if (fileInputRef.current) {
121 fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again
122 fileInputRef.current.click()
123 }
124 }
125
126 return (
127 <div className={className}>
128 <div onClick={handleUploadClick}>{children}</div>
129 <input
130 type="file"
131 ref={fileInputRef}
132 style={{ display: 'none' }}
133 onChange={handleFileChange}
134 accept={accept}
135 multiple
136 />
137 </div>
138 )
139 }
140