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'