feat(image): multiple files upload

This commit is contained in:
enes 2025-01-07 12:40:27 +01:00
parent 9fd1aca99c
commit 0026f4d751
5 changed files with 159 additions and 117 deletions

View File

@ -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>
)
}
)

View File

@ -1,4 +1,5 @@
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { v4 as uuidv4 } from 'uuid'
import { useMemo } from 'react' import { useMemo } from 'react'
import { FileRejection, FileWithPath } from 'react-dropzone' import { FileRejection, FileWithPath } from 'react-dropzone'
import { MediaInputError } from './MediaInputError' import { MediaInputError } from './MediaInputError'
@ -6,16 +7,15 @@ import { InputSuccess } from './Success'
import styles from './MediaInputPopover.module.scss' import styles from './MediaInputPopover.module.scss'
interface MediaInputPopoverProps { interface MediaInputPopoverProps {
name: string
acceptedFiles: readonly FileWithPath[] acceptedFiles: readonly FileWithPath[]
fileRejections: readonly FileRejection[] fileRejections: readonly FileRejection[]
} }
export const MediaInputPopover = ({ export const MediaInputPopover = ({
name,
acceptedFiles, acceptedFiles,
fileRejections fileRejections
}: MediaInputPopoverProps) => { }: MediaInputPopoverProps) => {
const uuid = useMemo(() => uuidv4(), [])
const acceptedFileItems = useMemo( const acceptedFileItems = useMemo(
() => () =>
acceptedFiles.map((file) => ( acceptedFiles.map((file) => (
@ -27,7 +27,7 @@ export const MediaInputPopover = ({
[acceptedFiles] [acceptedFiles]
) )
const fileRejectionItems = useMemo(() => { const fileRejectionItems = useMemo(() => {
const id = `${name}-errors` const id = `errors-${uuid}`
return ( return (
<div <div
className={`accordion accordion-flush ${styles.mediaInputError}`} className={`accordion accordion-flush ${styles.mediaInputError}`}
@ -45,7 +45,7 @@ export const MediaInputPopover = ({
))} ))}
</div> </div>
) )
}, [fileRejections, name]) }, [fileRejections, uuid])
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null if (acceptedFiles.length === 0 && fileRejections.length === 0) return null

View File

@ -1,15 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback } 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 { InputError } from './Error' import { InputError } from './Error'
import { MediaInputPopover } from './MediaInputPopover' import { ImageUpload } from './ImageUpload'
import '../../styles/styles.css'
interface InputFieldProps { interface InputFieldProps {
label: string | React.ReactElement label: string | React.ReactElement
@ -153,83 +145,40 @@ export const CheckboxFieldUncontrolled = ({
</div> </div>
) )
interface InputFieldWithImageUpload { interface InputFieldWithImageUploadProps {
label: string | React.ReactElement label: string | React.ReactElement
description?: string description?: string
multiple?: boolean | undefined
placeholder: string placeholder: string
name: string name: string
inputMode?: 'url' inputMode?: 'url'
value: string value: string
error?: string error?: string
onChange: (name: string, value: string) => void onInputChange: (name: string, value: string) => void
} }
export const InputFieldWithImageUpload = React.memo( export const InputFieldWithImageUpload = React.memo(
({ ({
label, label,
description, description,
multiple = false,
placeholder, placeholder,
name, name,
inputMode, inputMode,
value, value,
error, error,
onChange onInputChange
}: InputFieldWithImageUpload) => { }: InputFieldWithImageUploadProps) => {
const [mediaOption, setMediaOption] = useState<MediaOption>(
MEDIA_OPTIONS[0]
)
const handleOptionChange = useCallback(
(mo: MediaOption) => () => {
setMediaOption(mo)
},
[]
)
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { (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( const handleFileChange = useCallback(
() => (values: string[]) => {
isFileDialogActive onInputChange(name, values[0])
? 'Select files in dialog' },
: isDragActive [name, onInputChange]
? 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 ( return (
@ -239,50 +188,8 @@ export const InputFieldWithImageUpload = React.memo(
<p className='labelDescriptionMain'>{description}</p> <p className='labelDescriptionMain'>{description}</p>
)} )}
<div aria-label='upload featuredImageUrl' className='uploadBoxMain'> <ImageUpload onChange={handleFileChange} />
<MediaInputPopover
name={name}
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>
<input <input
type='text' type='text'
className='inputMain' className='inputMain'

View File

@ -30,6 +30,7 @@ import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor' import { Editor, EditorRef } from './Markdown/Editor'
import { MEDIA_OPTIONS } from 'controllers' import { MEDIA_OPTIONS } from 'controllers'
import { InputError } from './Inputs/Error' import { InputError } from './Inputs/Error'
import { ImageUpload } from './Inputs/ImageUpload'
interface GameOption { interface GameOption {
value: string value: string
@ -47,6 +48,7 @@ export const ModForm = () => {
const [formState, setFormState] = useState<ModFormState>( const [formState, setFormState] = useState<ModFormState>(
initializeFormState(mod) initializeFormState(mod)
) )
console.log(`[debug] screenshots`, formState.screenshotsUrls)
const editorRef = useRef<EditorRef>(null) const editorRef = useRef<EditorRef>(null)
useEffect(() => { useEffect(() => {
@ -230,7 +232,7 @@ export const ModForm = () => {
name='featuredImageUrl' name='featuredImageUrl'
value={formState.featuredImageUrl} value={formState.featuredImageUrl}
error={formErrors?.featuredImageUrl} error={formErrors?.featuredImageUrl}
onChange={handleInputChange} onInputChange={handleInputChange}
/> />
<InputField <InputField
label='Summary' label='Summary'
@ -294,8 +296,25 @@ export const ModForm = () => {
</button> </button>
</div> </div>
<p className='labelDescriptionMain'> <p className='labelDescriptionMain'>
We recommend to upload images to https://nostr.build/ We recommend to upload images to {MEDIA_OPTIONS[0].host}
</p> </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) => ( {formState.screenshotsUrls.map((url, index) => (
<Fragment key={`screenShot-${index}`}> <Fragment key={`screenShot-${index}`}>
<ScreenshotUrlFields <ScreenshotUrlFields
@ -608,7 +627,7 @@ const ScreenshotUrlFields = React.memo(
type='text' type='text'
className='inputMain' className='inputMain'
inputMode='url' inputMode='url'
placeholder='We recommend to upload images to https://nostr.build/' placeholder='Image URL'
value={url} value={url}
onChange={handleChange} onChange={handleChange}
/> />

View File

@ -108,7 +108,7 @@ export const WritePage = () => {
name='featuredImageUrl' name='featuredImageUrl'
value={featuredImageUrl} value={featuredImageUrl}
error={formErrors?.image} error={formErrors?.image}
onChange={(_, value) => setfeaturedImageUrl(value)} onInputChange={(_, value) => setfeaturedImageUrl(value)}
/> />
<InputFieldUncontrolled <InputFieldUncontrolled
label='Summary' label='Summary'