From 0026f4d75127678843fa0d65a43ebf8d1f2fbd67 Mon Sep 17 00:00:00 2001
From: enes <enes@nostrdev.com>
Date: Tue, 7 Jan 2025 12:40:27 +0100
Subject: [PATCH] feat(image): multiple files upload

---
 src/components/Inputs/ImageUpload.tsx       | 116 ++++++++++++++++++
 src/components/Inputs/MediaInputPopover.tsx |   8 +-
 src/components/Inputs/index.tsx             | 125 +++-----------------
 src/components/ModForm.tsx                  |  25 +++-
 src/pages/write/index.tsx                   |   2 +-
 5 files changed, 159 insertions(+), 117 deletions(-)
 create mode 100644 src/components/Inputs/ImageUpload.tsx

diff --git a/src/components/Inputs/ImageUpload.tsx b/src/components/Inputs/ImageUpload.tsx
new file mode 100644
index 0000000..717183a
--- /dev/null
+++ b/src/components/Inputs/ImageUpload.tsx
@@ -0,0 +1,116 @@
+import { useDropzone } from 'react-dropzone'
+import React, { useCallback, useMemo, useState } from 'react'
+import {
+  MediaOption,
+  MEDIA_OPTIONS,
+  ImageController,
+  MEDIA_DROPZONE_OPTIONS
+} from '../../controllers'
+import { errorFeedback } from '../../types'
+import { MediaInputPopover } from './MediaInputPopover'
+
+export interface ImageUploadProps {
+  multiple?: boolean | undefined
+  onChange: (values: string[]) => void
+}
+export const ImageUpload = React.memo(
+  ({ multiple = false, onChange }: ImageUploadProps) => {
+    const [mediaOption, setMediaOption] = useState<MediaOption>(
+      MEDIA_OPTIONS[0]
+    )
+    const handleOptionChange = useCallback(
+      (mo: MediaOption) => () => {
+        setMediaOption(mo)
+      },
+      []
+    )
+    const handleUpload = useCallback(
+      async (acceptedFiles: File[]) => {
+        if (acceptedFiles.length) {
+          try {
+            const imageController = new ImageController(mediaOption)
+            const urls: string[] = []
+            for (let i = 0; i < acceptedFiles.length; i++) {
+              const file = acceptedFiles[i]
+              urls.push(await imageController.post(file))
+            }
+            onChange(urls)
+          } catch (error) {
+            errorFeedback(error)
+          }
+        }
+      },
+      [mediaOption, onChange]
+    )
+    const {
+      getRootProps,
+      getInputProps,
+      isDragActive,
+      acceptedFiles,
+      isFileDialogActive,
+      isDragAccept,
+      isDragReject,
+      fileRejections
+    } = useDropzone({
+      ...MEDIA_DROPZONE_OPTIONS,
+      onDrop: handleUpload,
+      multiple: multiple
+    })
+
+    const dropzoneLabel = useMemo(
+      () =>
+        isFileDialogActive
+          ? 'Select files in dialog'
+          : isDragActive
+          ? isDragAccept
+            ? 'Drop the files here...'
+            : isDragReject
+            ? 'Drop the files here (one more more unsupported types)...'
+            : 'TODO'
+          : 'Click or drag files here',
+      [isDragAccept, isDragActive, isDragReject, isFileDialogActive]
+    )
+
+    return (
+      <div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
+        <MediaInputPopover
+          acceptedFiles={acceptedFiles}
+          fileRejections={fileRejections}
+        />
+        <div className='uploadBoxMainInside' {...getRootProps()} tabIndex={-1}>
+          <input id='featuredImageUrl-upload' {...getInputProps()} />
+
+          <span>{dropzoneLabel}</span>
+          <div
+            className='FiltersMainElement'
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className='dropdown dropdownMain'>
+              <button
+                className='btn dropdown-toggle btnMain btnMainDropdown'
+                aria-expanded='false'
+                data-bs-toggle='dropdown'
+                type='button'
+              >
+                Image Host: {mediaOption.name}
+              </button>
+              <div className='dropdown-menu dropdownMainMenu'>
+                {MEDIA_OPTIONS.map((mo) => {
+                  return (
+                    <div
+                      key={mo.host}
+                      onClick={handleOptionChange(mo)}
+                      className='dropdown-item dropdownMainMenuItem'
+                    >
+                      {mo.name}
+                    </div>
+                  )
+                })}
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    )
+  }
+)
diff --git a/src/components/Inputs/MediaInputPopover.tsx b/src/components/Inputs/MediaInputPopover.tsx
index 1f98de6..062ca4b 100644
--- a/src/components/Inputs/MediaInputPopover.tsx
+++ b/src/components/Inputs/MediaInputPopover.tsx
@@ -1,4 +1,5 @@
 import * as Popover from '@radix-ui/react-popover'
+import { v4 as uuidv4 } from 'uuid'
 import { useMemo } from 'react'
 import { FileRejection, FileWithPath } from 'react-dropzone'
 import { MediaInputError } from './MediaInputError'
@@ -6,16 +7,15 @@ import { InputSuccess } from './Success'
 import styles from './MediaInputPopover.module.scss'
 
 interface MediaInputPopoverProps {
-  name: string
   acceptedFiles: readonly FileWithPath[]
   fileRejections: readonly FileRejection[]
 }
 
 export const MediaInputPopover = ({
-  name,
   acceptedFiles,
   fileRejections
 }: MediaInputPopoverProps) => {
+  const uuid = useMemo(() => uuidv4(), [])
   const acceptedFileItems = useMemo(
     () =>
       acceptedFiles.map((file) => (
@@ -27,7 +27,7 @@ export const MediaInputPopover = ({
     [acceptedFiles]
   )
   const fileRejectionItems = useMemo(() => {
-    const id = `${name}-errors`
+    const id = `errors-${uuid}`
     return (
       <div
         className={`accordion accordion-flush ${styles.mediaInputError}`}
@@ -45,7 +45,7 @@ export const MediaInputPopover = ({
         ))}
       </div>
     )
-  }, [fileRejections, name])
+  }, [fileRejections, uuid])
 
   if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
 
diff --git a/src/components/Inputs/index.tsx b/src/components/Inputs/index.tsx
index 61c6774..1658d59 100644
--- a/src/components/Inputs/index.tsx
+++ b/src/components/Inputs/index.tsx
@@ -1,15 +1,7 @@
-import React, { useCallback, useMemo, useState } from 'react'
-import { useDropzone } from 'react-dropzone'
-import {
-  ImageController,
-  MEDIA_DROPZONE_OPTIONS,
-  MEDIA_OPTIONS,
-  MediaOption
-} from '../../controllers'
-import '../../styles/styles.css'
-import { errorFeedback } from '../../types'
+import React, { useCallback } from 'react'
 import { InputError } from './Error'
-import { MediaInputPopover } from './MediaInputPopover'
+import { ImageUpload } from './ImageUpload'
+import '../../styles/styles.css'
 
 interface InputFieldProps {
   label: string | React.ReactElement
@@ -153,83 +145,40 @@ export const CheckboxFieldUncontrolled = ({
   </div>
 )
 
-interface InputFieldWithImageUpload {
+interface InputFieldWithImageUploadProps {
   label: string | React.ReactElement
   description?: string
-  multiple?: boolean | undefined
   placeholder: string
   name: string
   inputMode?: 'url'
   value: string
   error?: string
-  onChange: (name: string, value: string) => void
+  onInputChange: (name: string, value: string) => void
 }
+
 export const InputFieldWithImageUpload = React.memo(
   ({
     label,
     description,
-    multiple = false,
     placeholder,
     name,
     inputMode,
     value,
     error,
-    onChange
-  }: InputFieldWithImageUpload) => {
-    const [mediaOption, setMediaOption] = useState<MediaOption>(
-      MEDIA_OPTIONS[0]
-    )
-    const handleOptionChange = useCallback(
-      (mo: MediaOption) => () => {
-        setMediaOption(mo)
-      },
-      []
-    )
+    onInputChange
+  }: InputFieldWithImageUploadProps) => {
     const handleChange = useCallback(
       (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
-        onChange(name, e.target.value)
+        onInputChange(name, e.currentTarget.value)
       },
-      [name, onChange]
+      [name, onInputChange]
     )
-    const handleUpload = useCallback(
-      async (acceptedFiles: File[]) => {
-        try {
-          const imageController = new ImageController(mediaOption)
-          const url = await imageController.post(acceptedFiles[0])
-          onChange(name, url)
-        } catch (error) {
-          errorFeedback(error)
-        }
-      },
-      [mediaOption, name, onChange]
-    )
-    const {
-      getRootProps,
-      getInputProps,
-      isDragActive,
-      acceptedFiles,
-      isFileDialogActive,
-      isDragAccept,
-      isDragReject,
-      fileRejections
-    } = useDropzone({
-      ...MEDIA_DROPZONE_OPTIONS,
-      onDrop: handleUpload,
-      multiple: multiple
-    })
 
-    const dropzoneLabel = useMemo(
-      () =>
-        isFileDialogActive
-          ? 'Select files in dialog'
-          : isDragActive
-          ? isDragAccept
-            ? 'Drop the files here...'
-            : isDragReject
-            ? 'Drop the files here (one more more unsupported types)...'
-            : 'TODO'
-          : 'Click or drag files here',
-      [isDragAccept, isDragActive, isDragReject, isFileDialogActive]
+    const handleFileChange = useCallback(
+      (values: string[]) => {
+        onInputChange(name, values[0])
+      },
+      [name, onInputChange]
     )
 
     return (
@@ -239,50 +188,8 @@ export const InputFieldWithImageUpload = React.memo(
           <p className='labelDescriptionMain'>{description}</p>
         )}
 
-        <div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
-          <MediaInputPopover
-            name={name}
-            acceptedFiles={acceptedFiles}
-            fileRejections={fileRejections}
-          />
-          <div
-            className='uploadBoxMainInside'
-            {...getRootProps()}
-            tabIndex={-1}
-          >
-            <input id='featuredImageUrl-upload' {...getInputProps()} />
+        <ImageUpload onChange={handleFileChange} />
 
-            <span>{dropzoneLabel}</span>
-            <div
-              className='FiltersMainElement'
-              onClick={(e) => e.stopPropagation()}
-            >
-              <div className='dropdown dropdownMain'>
-                <button
-                  className='btn dropdown-toggle btnMain btnMainDropdown'
-                  aria-expanded='false'
-                  data-bs-toggle='dropdown'
-                  type='button'
-                >
-                  Image Host: {mediaOption.name}
-                </button>
-                <div className='dropdown-menu dropdownMainMenu'>
-                  {MEDIA_OPTIONS.map((mo) => {
-                    return (
-                      <div
-                        key={mo.host}
-                        onClick={handleOptionChange(mo)}
-                        className='dropdown-item dropdownMainMenuItem'
-                      >
-                        {mo.name}
-                      </div>
-                    )
-                  })}
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
         <input
           type='text'
           className='inputMain'
diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx
index 4f852c1..5c67b9d 100644
--- a/src/components/ModForm.tsx
+++ b/src/components/ModForm.tsx
@@ -30,6 +30,7 @@ import { AlertPopup } from './AlertPopup'
 import { Editor, EditorRef } from './Markdown/Editor'
 import { MEDIA_OPTIONS } from 'controllers'
 import { InputError } from './Inputs/Error'
+import { ImageUpload } from './Inputs/ImageUpload'
 
 interface GameOption {
   value: string
@@ -47,6 +48,7 @@ export const ModForm = () => {
   const [formState, setFormState] = useState<ModFormState>(
     initializeFormState(mod)
   )
+  console.log(`[debug] screenshots`, formState.screenshotsUrls)
   const editorRef = useRef<EditorRef>(null)
 
   useEffect(() => {
@@ -230,7 +232,7 @@ export const ModForm = () => {
         name='featuredImageUrl'
         value={formState.featuredImageUrl}
         error={formErrors?.featuredImageUrl}
-        onChange={handleInputChange}
+        onInputChange={handleInputChange}
       />
       <InputField
         label='Summary'
@@ -294,8 +296,25 @@ export const ModForm = () => {
           </button>
         </div>
         <p className='labelDescriptionMain'>
-          We recommend to upload images to https://nostr.build/
+          We recommend to upload images to {MEDIA_OPTIONS[0].host}
         </p>
+
+        <ImageUpload
+          multiple={true}
+          onChange={(values) => {
+            // values.forEach((screenshotUrl) => addScreenshotUrl())
+            setFormState((prevState) => ({
+              ...prevState,
+              screenshotsUrls: Array.from(
+                new Set([
+                  ...prevState.screenshotsUrls.filter((url) => url),
+                  ...values
+                ])
+              )
+            }))
+          }}
+        />
+
         {formState.screenshotsUrls.map((url, index) => (
           <Fragment key={`screenShot-${index}`}>
             <ScreenshotUrlFields
@@ -608,7 +627,7 @@ const ScreenshotUrlFields = React.memo(
           type='text'
           className='inputMain'
           inputMode='url'
-          placeholder='We recommend to upload images to https://nostr.build/'
+          placeholder='Image URL'
           value={url}
           onChange={handleChange}
         />
diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx
index 36d415e..9f21d31 100644
--- a/src/pages/write/index.tsx
+++ b/src/pages/write/index.tsx
@@ -108,7 +108,7 @@ export const WritePage = () => {
                   name='featuredImageUrl'
                   value={featuredImageUrl}
                   error={formErrors?.image}
-                  onChange={(_, value) => setfeaturedImageUrl(value)}
+                  onInputChange={(_, value) => setfeaturedImageUrl(value)}
                 />
                 <InputFieldUncontrolled
                   label='Summary'