feat(image): multiple files upload
This commit is contained in:
parent
9fd1aca99c
commit
0026f4d751
116
src/components/Inputs/ImageUpload.tsx
Normal file
116
src/components/Inputs/ImageUpload.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
@ -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
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user