Direct image upload #184
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 { 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
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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'
|
||||
|
Loading…
x
Reference in New Issue
Block a user