Direct image upload #184

Merged
enes merged 11 commits from 106-direct-image-upload into staging 2025-01-07 18:33:56 +00:00
5 changed files with 159 additions and 117 deletions
Showing only changes of commit 0026f4d751 - Show all commits

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 { 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

View File

@ -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'

View File

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

View File

@ -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'