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