chore(git): merge pull request #184 from 106-direct-image-upload into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #184
This commit is contained in:
commit
6170050070
@ -1,10 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
env: { browser: true, es2022: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
@ -12,7 +12,7 @@ module.exports = {
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
{ allowConstantExport: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
39
package-lock.json
generated
39
package-lock.json
generated
@ -32,6 +32,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
@ -3937,6 +3938,15 @@
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/attributes-parser": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/attributes-parser/-/attributes-parser-2.2.3.tgz",
|
||||
@ -5073,6 +5083,18 @@
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -7343,6 +7365,23 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz",
|
||||
"integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
|
||||
|
@ -34,6 +34,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
|
14
src/components/Inputs/Error.tsx
Normal file
14
src/components/Inputs/Error.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
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)...'
|
||||
: 'Drop the files here...'
|
||||
: '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>
|
||||
)
|
||||
}
|
||||
)
|
14
src/components/Inputs/MediaInputError.module.scss
Normal file
14
src/components/Inputs/MediaInputError.module.scss
Normal file
@ -0,0 +1,14 @@
|
||||
.accordion-button::after {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
|
||||
top: unset !important;
|
||||
bottom: unset !important;
|
||||
}
|
||||
.accordion-body > * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.accordion-item + .accordion-item {
|
||||
margin-top: 10px;
|
||||
}
|
64
src/components/Inputs/MediaInputError.tsx
Normal file
64
src/components/Inputs/MediaInputError.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { FileError } from 'react-dropzone'
|
||||
import styles from './MediaInputError.module.scss'
|
||||
|
||||
type MediaInputErrorProps = {
|
||||
rootId: string
|
||||
index: number
|
||||
message: string
|
||||
errors?: readonly FileError[] | undefined
|
||||
}
|
||||
|
||||
export const MediaInputError = ({
|
||||
rootId,
|
||||
index,
|
||||
message,
|
||||
errors
|
||||
}: MediaInputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className={['accordion-item', styles['accordion-item']].join(' ')}>
|
||||
<h2 className='accordion-header' role='tab'>
|
||||
<button
|
||||
className={[
|
||||
'accordion-button collapsed',
|
||||
styles['accordion-button']
|
||||
].join(' ')}
|
||||
type='button'
|
||||
data-bs-toggle='collapse'
|
||||
data-bs-target={`#${rootId} .item-${index}`}
|
||||
aria-expanded='false'
|
||||
aria-controls={`${rootId} .item-${index}`}
|
||||
>
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
{errors && (
|
||||
<div
|
||||
className={`accordion-collapse collapse item-${index}`}
|
||||
role='tabpanel'
|
||||
data-bs-parent={`#${rootId}`}
|
||||
>
|
||||
<div
|
||||
className={['accordion-body', styles['accordion-body']].join(' ')}
|
||||
>
|
||||
{errors.map((e) => {
|
||||
return typeof e === 'string' ? (
|
||||
<div className='errorMain' key={e}>
|
||||
{e}
|
||||
</div>
|
||||
) : (
|
||||
<div className='errorMain' key={e.code}>
|
||||
{e.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
45
src/components/Inputs/MediaInputPopover.module.scss
Normal file
45
src/components/Inputs/MediaInputPopover.module.scss
Normal file
@ -0,0 +1,45 @@
|
||||
.popover {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 0 16px 0px rgb(0 0 0 / 15%);
|
||||
background: #232323;
|
||||
z-index: 2;
|
||||
}
|
||||
.content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 25px;
|
||||
> *:not(:first-child) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
.trigger {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.mediaInputError {
|
||||
--bs-accordion-color: unset;
|
||||
--bs-accordion-bg: unset;
|
||||
--bs-accordion-transition: unset;
|
||||
--bs-accordion-border-color: unset;
|
||||
--bs-accordion-border-width: unset;
|
||||
--bs-accordion-border-radius: unset;
|
||||
--bs-accordion-inner-border-radius: unset;
|
||||
--bs-accordion-btn-padding-x: unset;
|
||||
--bs-accordion-btn-padding-y: unset;
|
||||
--bs-accordion-btn-color: unset;
|
||||
--bs-accordion-btn-bg: unset;
|
||||
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-icon-width: 1.25rem;
|
||||
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-focus-border-color: unset;
|
||||
--bs-accordion-btn-focus-box-shadow: unset;
|
||||
--bs-accordion-body-padding-x: unset;
|
||||
--bs-accordion-body-padding-y: unset;
|
||||
--bs-accordion-active-color: unset;
|
||||
--bs-accordion-active-bg: unset;
|
||||
}
|
108
src/components/Inputs/MediaInputPopover.tsx
Normal file
108
src/components/Inputs/MediaInputPopover.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
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'
|
||||
import { InputSuccess } from './Success'
|
||||
import styles from './MediaInputPopover.module.scss'
|
||||
|
||||
interface MediaInputPopoverProps {
|
||||
acceptedFiles: readonly FileWithPath[]
|
||||
fileRejections: readonly FileRejection[]
|
||||
}
|
||||
|
||||
export const MediaInputPopover = ({
|
||||
acceptedFiles,
|
||||
fileRejections
|
||||
}: MediaInputPopoverProps) => {
|
||||
const uuid = useMemo(() => uuidv4(), [])
|
||||
const acceptedFileItems = useMemo(
|
||||
() =>
|
||||
acceptedFiles.map((file) => (
|
||||
<InputSuccess
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
/>
|
||||
)),
|
||||
[acceptedFiles]
|
||||
)
|
||||
const fileRejectionItems = useMemo(() => {
|
||||
const id = `errors-${uuid}`
|
||||
return (
|
||||
<div
|
||||
className={`accordion accordion-flush ${styles.mediaInputError}`}
|
||||
role='tablist'
|
||||
id={id}
|
||||
>
|
||||
{fileRejections.map(({ file, errors }, index) => (
|
||||
<MediaInputError
|
||||
rootId={id}
|
||||
index={index}
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}, [fileRejections, uuid])
|
||||
|
||||
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<div className={styles.trigger}>
|
||||
{acceptedFiles.length > 0 ? (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm211.3-43.3c-6.2-6.2-16.4-6.2-22.6 0L416 385.4l-28.7-28.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l40 40c6.2 6.2 16.4 6.2 22.6 0l72-72c6.2-6.2 6.2-16.4 0-22.6z' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='tomato'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zm48 96a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm0 240a24 24 0 1 0 0-48 24 24 0 1 0 0 48zm0-192c-8.8 0-16 7.2-16 16l0 80c0 8.8 7.2 16 16 16s16-7.2 16-16l0-80c0-8.8-7.2-16-16-16z' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className={styles.popover} sideOffset={5}>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Selected files</h3>
|
||||
</div>
|
||||
<Popover.Close asChild aria-label='Close'>
|
||||
<div className='popUpMainCardTopClose'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Popover.Close>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{acceptedFileItems}
|
||||
{fileRejectionItems}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
14
src/components/Inputs/Success.tsx
Normal file
14
src/components/Inputs/Success.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
type InputSuccessProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputSuccess = ({ message }: InputSuccessProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='successMain'>
|
||||
<div className='successMainColor'></div>
|
||||
<p className='successMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
import '../styles/styles.css'
|
||||
import React, { useCallback } from 'react'
|
||||
import { InputError } from './Error'
|
||||
import { ImageUpload } from './ImageUpload'
|
||||
import '../../styles/styles.css'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string | React.ReactElement
|
||||
@ -60,21 +62,6 @@ export const InputField = React.memo(
|
||||
}
|
||||
)
|
||||
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
@ -157,3 +144,63 @@ export const CheckboxFieldUncontrolled = ({
|
||||
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||
</div>
|
||||
)
|
||||
|
||||
interface InputFieldWithImageUploadProps {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputFieldWithImageUpload = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onInputChange
|
||||
}: InputFieldWithImageUploadProps) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onInputChange(name, e.currentTarget.value)
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(values: string[]) => {
|
||||
onInputChange(name, values[0])
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{typeof description !== 'undefined' && (
|
||||
<p className='labelDescriptionMain'>{description}</p>
|
||||
)}
|
||||
|
||||
<ImageUpload onChange={handleFileChange} />
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -23,11 +23,14 @@ import {
|
||||
ModPageLoaderResult
|
||||
} from '../types'
|
||||
import { initializeFormState } from '../utils'
|
||||
import { CheckboxField, InputError, InputField } from './Inputs'
|
||||
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
|
||||
import { OriginalAuthor } from './OriginalAuthor'
|
||||
import { CategoryAutocomplete } from './CategoryAutocomplete'
|
||||
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
|
||||
@ -220,16 +223,15 @@ export const ModForm = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
<InputFieldWithImageUpload
|
||||
label='Featured Image URL'
|
||||
description='We recommend to upload images to https://imgur.com/upload'
|
||||
type='text'
|
||||
description={`We recommend to upload images to ${MEDIA_OPTIONS[0].host}`}
|
||||
inputMode='url'
|
||||
placeholder='Image URL'
|
||||
name='featuredImageUrl'
|
||||
value={formState.featuredImageUrl}
|
||||
error={formErrors?.featuredImageUrl}
|
||||
onChange={handleInputChange}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
<InputField
|
||||
label='Summary'
|
||||
@ -293,8 +295,24 @@ export const ModForm = () => {
|
||||
</button>
|
||||
</div>
|
||||
<p className='labelDescriptionMain'>
|
||||
We recommend to upload images to https://imgur.com/upload
|
||||
We recommend to upload images to {MEDIA_OPTIONS[0].host}
|
||||
</p>
|
||||
|
||||
<ImageUpload
|
||||
multiple={true}
|
||||
onChange={(values) => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
screenshotsUrls: Array.from(
|
||||
new Set([
|
||||
...prevState.screenshotsUrls.filter((url) => url),
|
||||
...values
|
||||
])
|
||||
)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
{formState.screenshotsUrls.map((url, index) => (
|
||||
<Fragment key={`screenShot-${index}`}>
|
||||
<ScreenshotUrlFields
|
||||
@ -607,7 +625,7 @@ const ScreenshotUrlFields = React.memo(
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='url'
|
||||
placeholder='We recommend to upload images to https://imgur.com/upload'
|
||||
placeholder='Image URL'
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
74
src/controllers/image/index.ts
Normal file
74
src/controllers/image/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { DropzoneOptions } from 'react-dropzone'
|
||||
import { NostrCheckServer } from './nostrcheck-server'
|
||||
import { BaseError } from 'types'
|
||||
|
||||
export interface MediaOperations {
|
||||
post: (file: File) => Promise<string>
|
||||
}
|
||||
export type MediaStrategy = Omit<MediaOperations, 'auth'>
|
||||
|
||||
export interface MediaOption {
|
||||
name: string
|
||||
host: string
|
||||
type: 'nostrcheck-server' | 'route96'
|
||||
}
|
||||
|
||||
// nostr.build based dropzone options
|
||||
export const MEDIA_DROPZONE_OPTIONS: DropzoneOptions = {
|
||||
maxSize: 7000000,
|
||||
accept: {
|
||||
'image/*': ['.jpeg', '.png', '.jpg', '.gif', '.webp']
|
||||
}
|
||||
}
|
||||
|
||||
export const MEDIA_OPTIONS: MediaOption[] = [
|
||||
// {
|
||||
// name: 'nostr.build',
|
||||
// host: 'https://nostr.build/',
|
||||
// type: 'nostrcheck-server'
|
||||
// },
|
||||
{
|
||||
name: 'nostrcheck.me',
|
||||
host: 'https://nostrcheck.me/',
|
||||
type: 'nostrcheck-server'
|
||||
},
|
||||
{
|
||||
name: 'nostpic.com',
|
||||
host: 'https://nostpic.com/',
|
||||
type: 'nostrcheck-server'
|
||||
},
|
||||
{
|
||||
name: 'files.sovbit.host',
|
||||
host: 'https://files.sovbit.host/',
|
||||
type: 'nostrcheck-server'
|
||||
}
|
||||
// {
|
||||
// name: 'void.cat',
|
||||
// host: 'https://void.cat/',
|
||||
// type: 'route96'
|
||||
// }
|
||||
]
|
||||
|
||||
enum ImageErrorType {
|
||||
'TYPE_MISSING' = 'Media Option must include a type.'
|
||||
}
|
||||
|
||||
export class ImageController implements MediaStrategy {
|
||||
post: (file: File) => Promise<string>
|
||||
|
||||
constructor(mediaOption: MediaOption) {
|
||||
let strategy: MediaStrategy
|
||||
switch (mediaOption.type) {
|
||||
case 'nostrcheck-server':
|
||||
strategy = new NostrCheckServer(mediaOption.host)
|
||||
this.post = strategy.post
|
||||
break
|
||||
|
||||
case 'route96':
|
||||
throw new Error('Not implemented.')
|
||||
|
||||
default:
|
||||
throw new BaseError(ImageErrorType.TYPE_MISSING)
|
||||
}
|
||||
}
|
||||
}
|
162
src/controllers/image/nostrcheck-server.ts
Normal file
162
src/controllers/image/nostrcheck-server.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import axios, { isAxiosError } from 'axios'
|
||||
import { NostrEvent, NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { type MediaOperations } from '.'
|
||||
import { store } from 'store'
|
||||
import { now } from 'utils'
|
||||
import { BaseError, handleError } from 'types'
|
||||
|
||||
// https://github.com/quentintaranpino/nostrcheck-server/blob/main/DOCS.md#media-post
|
||||
// Response object (other fields omitted for brevity)
|
||||
// {
|
||||
// "status": "success",
|
||||
// "nip94_event": {
|
||||
// "tags": [
|
||||
// [
|
||||
// "url",
|
||||
// "https://nostrcheck.me/media/62c76eb094369d938f5895442eef7f53ebbf019f69707d64e77d4d182b609309/c35277dbcedebb0e3b80361762c8baadb66dcdfb6396949e50630159a472c3b2.webp"
|
||||
// ],
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
|
||||
interface Response {
|
||||
status: 'success' | string
|
||||
nip94_event?: {
|
||||
tags?: string[][]
|
||||
}
|
||||
}
|
||||
|
||||
enum HandledErrorType {
|
||||
'PUBKEY' = 'Failed to get public key.',
|
||||
'SIGN' = 'Failed to sign the event.',
|
||||
'AXIOS_REQ' = 'Image upload failed. Try another host from the dropdown.',
|
||||
'AXIOS_RES' = 'Image upload failed. Reason: ',
|
||||
'AXIOS_ERR' = 'Image upload failed.',
|
||||
'NOSTR_CHECK_NO_SUCCESS' = 'Image upload was unsuccesfull.',
|
||||
'NOSTR_CHECK_BAD_EVENT' = 'Image upload failed. Please try again.'
|
||||
}
|
||||
|
||||
export class NostrCheckServer implements MediaOperations {
|
||||
#media = 'api/v2/media'
|
||||
#url: string
|
||||
|
||||
constructor(url: string) {
|
||||
this.#url = url[url.length - 1] === '/' ? url : `${url}/`
|
||||
}
|
||||
|
||||
post = async (file: File) => {
|
||||
const url = `${this.#url}${this.#media}`
|
||||
const auth = await this.auth()
|
||||
try {
|
||||
const response = await axios.postForm<Response>(
|
||||
url,
|
||||
{
|
||||
uploadType: 'media',
|
||||
file: file
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + auth,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
responseType: 'json'
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.status !== 'success') {
|
||||
throw new BaseError(HandledErrorType.NOSTR_CHECK_NO_SUCCESS, {
|
||||
context: { ...response.data }
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
response.data.nip94_event &&
|
||||
response.data.nip94_event.tags &&
|
||||
response.data.nip94_event.tags.length
|
||||
) {
|
||||
// Return first 'url' tag we find on the returned nip94 event
|
||||
const imageUrl = response.data.nip94_event.tags.find(
|
||||
(item) => item[0] === 'url'
|
||||
)
|
||||
|
||||
if (imageUrl) return imageUrl[1]
|
||||
}
|
||||
|
||||
throw new BaseError(HandledErrorType.NOSTR_CHECK_BAD_EVENT, {
|
||||
context: { ...response.data }
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle axios errors
|
||||
if (isAxiosError(error)) {
|
||||
if (error.request) {
|
||||
// The request was made but no response was received
|
||||
throw new BaseError(HandledErrorType.AXIOS_REQ, {
|
||||
cause: error
|
||||
})
|
||||
} else if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
// nostrcheck-server can return different results, including message or description
|
||||
const data = error.response.data
|
||||
let message = error.message
|
||||
if (data) {
|
||||
message = data?.message || data?.description || error.message
|
||||
}
|
||||
throw new BaseError(HandledErrorType.AXIOS_RES + message, {
|
||||
cause: error
|
||||
})
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
throw new BaseError(HandledErrorType.AXIOS_ERR, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
} else if (error instanceof BaseError) {
|
||||
throw error
|
||||
} else {
|
||||
throw handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth = async () => {
|
||||
try {
|
||||
const url = `${this.#url}${this.#media}`
|
||||
|
||||
let hexPubkey: string
|
||||
const userState = store.getState().user
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!hexPubkey) {
|
||||
throw new BaseError(HandledErrorType.PUBKEY)
|
||||
}
|
||||
|
||||
const unsignedEvent: NostrEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: NDKKind.HttpAuth,
|
||||
pubkey: hexPubkey,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST']
|
||||
]
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr?.signEvent(unsignedEvent)
|
||||
return btoa(JSON.stringify(signedEvent))
|
||||
} catch (error) {
|
||||
if (error instanceof BaseError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new BaseError(HandledErrorType.SIGN, {
|
||||
cause: handleError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
7
src/controllers/image/route96.ts
Normal file
7
src/controllers/image/route96.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { MediaOperations } from '.'
|
||||
|
||||
export class route96 implements MediaOperations {
|
||||
post = () => {
|
||||
throw new Error('route96 post Not implemented.')
|
||||
}
|
||||
}
|
@ -1 +1,2 @@
|
||||
export * from './zap'
|
||||
export * from './image'
|
||||
|
@ -7,8 +7,8 @@ import {
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
CheckboxFieldUncontrolled,
|
||||
InputError,
|
||||
InputFieldUncontrolled
|
||||
InputFieldUncontrolled,
|
||||
InputFieldWithImageUpload
|
||||
} from '../../components/Inputs'
|
||||
import { ProfileSection } from '../../components/ProfileSection'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
@ -19,6 +19,7 @@ import '../../styles/write.css'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { AlertPopup } from 'components/AlertPopup'
|
||||
import { Editor, EditorRef } from 'components/Markdown/Editor'
|
||||
import { InputError } from 'components/Inputs/Error'
|
||||
|
||||
export const WritePage = () => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
@ -29,8 +30,11 @@ export const WritePage = () => {
|
||||
const blog = data?.blog
|
||||
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
|
||||
const [content, setContent] = useState(blog?.content || '')
|
||||
const [image, setImage] = useState(blog?.image || '')
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const editorRef = useRef<EditorRef>(null)
|
||||
|
||||
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||
const handleReset = () => {
|
||||
setShowConfirmPopup(true)
|
||||
@ -41,6 +45,9 @@ export const WritePage = () => {
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
|
||||
// Reset featured image
|
||||
setImage(blog?.image || '')
|
||||
|
||||
// Reset editor
|
||||
if (blog?.content) {
|
||||
editorRef.current?.setMarkdown(blog?.content)
|
||||
@ -97,12 +104,14 @@ export const WritePage = () => {
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<InputFieldUncontrolled
|
||||
<InputFieldWithImageUpload
|
||||
label='Featured Image URL'
|
||||
name='image'
|
||||
inputMode='url'
|
||||
defaultValue={blog?.image}
|
||||
value={image}
|
||||
error={formErrors?.image}
|
||||
onInputChange={(_, value) => setImage(value)}
|
||||
placeholder='Image URL'
|
||||
/>
|
||||
<InputFieldUncontrolled
|
||||
label='Summary'
|
||||
|
@ -661,6 +661,7 @@ a:hover {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.successMain,
|
||||
.errorMain {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
@ -671,13 +672,17 @@ a:hover {
|
||||
flex-direction: row;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.successMainColor,
|
||||
.errorMainColor {
|
||||
width: 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.errorMainColor {
|
||||
background: tomato;
|
||||
}
|
||||
|
||||
.successMainColor {
|
||||
background: #60ae60;
|
||||
}
|
||||
.errorMainText {
|
||||
}
|
||||
|
||||
@ -709,12 +714,14 @@ a:hover {
|
||||
.uploadBoxMain {
|
||||
background: hsl(0deg 0% 0% / 10%);
|
||||
border-radius: 10px;
|
||||
height: 10px;
|
||||
height: 150px;
|
||||
padding: 10px;
|
||||
border: solid 1px hsl(0deg 0% 100% / 5%);
|
||||
transition: padding ease-in-out 0.4s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.uploadBoxMain:hover > .uploadBoxMainInside {
|
||||
.uploadBoxMain:hover {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@ -729,4 +736,5 @@ a:hover {
|
||||
align-items: center;
|
||||
color: hsl(0deg 0% 100% / 20%);
|
||||
grid-gap: 10px;
|
||||
}
|
||||
cursor: pointer;
|
||||
}
|
||||
|
58
src/types/errors.ts
Normal file
58
src/types/errors.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { toast } from 'react-toastify'
|
||||
import { log, LogType } from 'utils'
|
||||
|
||||
export type Jsonable =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| readonly Jsonable[]
|
||||
| { readonly [key: string]: Jsonable }
|
||||
| { toJSON(): Jsonable }
|
||||
|
||||
/**
|
||||
* Handle errors
|
||||
* Wraps the errors without message property and stringify to a message so we can use it later
|
||||
* @param error
|
||||
* @returns
|
||||
*/
|
||||
export function handleError(error: unknown): Error {
|
||||
if (error instanceof Error) return error
|
||||
|
||||
// No message error, wrap it and stringify
|
||||
let stringified = 'Unable to stringify the thrown value'
|
||||
try {
|
||||
stringified = JSON.stringify(error)
|
||||
} catch (error) {
|
||||
console.error(stringified, error)
|
||||
}
|
||||
|
||||
return new Error(`Stringified Error: ${stringified}`)
|
||||
}
|
||||
|
||||
export class BaseError extends Error {
|
||||
public readonly context?: Jsonable
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: { cause?: Error; context?: Jsonable } = {}
|
||||
) {
|
||||
const { cause, context } = options
|
||||
|
||||
super(message, { cause: cause })
|
||||
this.name = this.constructor.name
|
||||
|
||||
this.context = context
|
||||
}
|
||||
}
|
||||
|
||||
export function errorFeedback(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
log(true, LogType.Error, error)
|
||||
} else {
|
||||
toast.error('Something went wrong.')
|
||||
log(true, LogType.Error, error)
|
||||
}
|
||||
}
|
@ -6,3 +6,4 @@ export * from './zap'
|
||||
export * from './blog'
|
||||
export * from './category'
|
||||
export * from './popup'
|
||||
export * from './errors'
|
||||
|
@ -2,9 +2,9 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user