Direct image upload #184

Merged
enes merged 11 commits from 106-direct-image-upload into staging 2025-01-07 18:33:56 +00:00
21 changed files with 840 additions and 40 deletions

View File

@ -1,10 +1,10 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2020: true }, env: { browser: true, es2022: true },
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended', 'plugin:react-hooks/recommended'
], ],
ignorePatterns: ['dist', '.eslintrc.cjs'], ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
@ -12,7 +12,7 @@ module.exports = {
rules: { rules: {
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': [
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true }
], ]
}, }
} }

39
package-lock.json generated
View File

@ -32,6 +32,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-countdown": "2.3.5", "react-countdown": "2.3.5",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-redux": "9.1.2", "react-redux": "9.1.2",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.24.1",
@ -3937,6 +3938,15 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "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": { "node_modules/attributes-parser": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/attributes-parser/-/attributes-parser-2.2.3.tgz", "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", "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -7343,6 +7365,23 @@
"react": "^18.3.1" "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": { "node_modules/react-error-boundary": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",

View File

@ -34,6 +34,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-countdown": "2.3.5", "react-countdown": "2.3.5",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-redux": "9.1.2", "react-redux": "9.1.2",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.24.1",

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

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)...'
: '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>
)
}
)

View 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;
}

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

View 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;
}

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

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

View File

@ -1,5 +1,7 @@
import React from 'react' import React, { useCallback } from 'react'
import '../styles/styles.css' import { InputError } from './Error'
import { ImageUpload } from './ImageUpload'
import '../../styles/styles.css'
interface InputFieldProps { interface InputFieldProps {
label: string | React.ReactElement 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 { interface CheckboxFieldProps {
label: string label: string
name: string name: string
@ -157,3 +144,63 @@ export const CheckboxFieldUncontrolled = ({
<input type='checkbox' className='CheckboxMain' {...rest} /> <input type='checkbox' className='CheckboxMain' {...rest} />
</div> </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>
)
}
)

View File

@ -23,11 +23,14 @@ import {
ModPageLoaderResult ModPageLoaderResult
} from '../types' } from '../types'
import { initializeFormState } from '../utils' import { initializeFormState } from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs' import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
import { OriginalAuthor } from './OriginalAuthor' import { OriginalAuthor } from './OriginalAuthor'
import { CategoryAutocomplete } from './CategoryAutocomplete' import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup' import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor' import { Editor, EditorRef } from './Markdown/Editor'
import { MEDIA_OPTIONS } from 'controllers'
import { InputError } from './Inputs/Error'
import { ImageUpload } from './Inputs/ImageUpload'
interface GameOption { interface GameOption {
value: string value: string
@ -220,16 +223,15 @@ export const ModForm = () => {
/> />
</div> </div>
<InputField <InputFieldWithImageUpload
label='Featured Image URL' label='Featured Image URL'
description='We recommend to upload images to https://imgur.com/upload' description={`We recommend to upload images to ${MEDIA_OPTIONS[0].host}`}
type='text'
inputMode='url' inputMode='url'
placeholder='Image URL' placeholder='Image URL'
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'
@ -293,8 +295,24 @@ export const ModForm = () => {
</button> </button>
</div> </div>
<p className='labelDescriptionMain'> <p className='labelDescriptionMain'>
We recommend to upload images to https://imgur.com/upload We recommend to upload images to {MEDIA_OPTIONS[0].host}
</p> </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) => ( {formState.screenshotsUrls.map((url, index) => (
<Fragment key={`screenShot-${index}`}> <Fragment key={`screenShot-${index}`}>
<ScreenshotUrlFields <ScreenshotUrlFields
@ -607,7 +625,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://imgur.com/upload' placeholder='Image URL'
value={url} value={url}
onChange={handleChange} onChange={handleChange}
/> />

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

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

View File

@ -0,0 +1,7 @@
import { MediaOperations } from '.'
export class route96 implements MediaOperations {
post = () => {
throw new Error('route96 post Not implemented.')
}
}

View File

@ -1 +1,2 @@
export * from './zap' export * from './zap'
export * from './image'

View File

@ -7,8 +7,8 @@ import {
} from 'react-router-dom' } from 'react-router-dom'
import { import {
CheckboxFieldUncontrolled, CheckboxFieldUncontrolled,
InputError, InputFieldUncontrolled,
InputFieldUncontrolled InputFieldWithImageUpload
} from '../../components/Inputs' } from '../../components/Inputs'
import { ProfileSection } from '../../components/ProfileSection' import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
@ -19,6 +19,7 @@ import '../../styles/write.css'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { AlertPopup } from 'components/AlertPopup' import { AlertPopup } from 'components/AlertPopup'
import { Editor, EditorRef } from 'components/Markdown/Editor' import { Editor, EditorRef } from 'components/Markdown/Editor'
import { InputError } from 'components/Inputs/Error'
export const WritePage = () => { export const WritePage = () => {
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
@ -29,8 +30,11 @@ export const WritePage = () => {
const blog = data?.blog const blog = data?.blog
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post' const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const [content, setContent] = useState(blog?.content || '') const [content, setContent] = useState(blog?.content || '')
const [image, setImage] = useState(blog?.image || '')
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const editorRef = useRef<EditorRef>(null) const editorRef = useRef<EditorRef>(null)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false) const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => { const handleReset = () => {
setShowConfirmPopup(true) setShowConfirmPopup(true)
@ -41,6 +45,9 @@ export const WritePage = () => {
// Cancel if not confirmed // Cancel if not confirmed
if (!confirm) return if (!confirm) return
// Reset featured image
setImage(blog?.image || '')
// Reset editor // Reset editor
if (blog?.content) { if (blog?.content) {
editorRef.current?.setMarkdown(blog?.content) editorRef.current?.setMarkdown(blog?.content)
@ -97,12 +104,14 @@ export const WritePage = () => {
readOnly readOnly
/> />
</div> </div>
<InputFieldUncontrolled <InputFieldWithImageUpload
label='Featured Image URL' label='Featured Image URL'
name='image' name='image'
inputMode='url' inputMode='url'
defaultValue={blog?.image} value={image}
error={formErrors?.image} error={formErrors?.image}
onInputChange={(_, value) => setImage(value)}
placeholder='Image URL'
/> />
<InputFieldUncontrolled <InputFieldUncontrolled
label='Summary' label='Summary'

View File

@ -661,6 +661,7 @@ a:hover {
padding-right: 50px; padding-right: 50px;
} }
.successMain,
.errorMain { .errorMain {
width: 100%; width: 100%;
border-radius: 10px; border-radius: 10px;
@ -671,13 +672,17 @@ a:hover {
flex-direction: row; flex-direction: row;
grid-gap: 10px; grid-gap: 10px;
} }
.successMainColor,
.errorMainColor { .errorMainColor {
width: 5px; width: 5px;
border-radius: 2px; border-radius: 2px;
}
.errorMainColor {
background: tomato; background: tomato;
} }
.successMainColor {
background: #60ae60;
}
.errorMainText { .errorMainText {
} }
@ -709,12 +714,14 @@ a:hover {
.uploadBoxMain { .uploadBoxMain {
background: hsl(0deg 0% 0% / 10%); background: hsl(0deg 0% 0% / 10%);
border-radius: 10px; border-radius: 10px;
height: 10px; height: 150px;
padding: 10px; padding: 10px;
border: solid 1px hsl(0deg 0% 100% / 5%); border: solid 1px hsl(0deg 0% 100% / 5%);
transition: padding ease-in-out 0.4s;
position: relative;
} }
.uploadBoxMain:hover > .uploadBoxMainInside { .uploadBoxMain:hover {
padding: 5px; padding: 5px;
} }
@ -729,4 +736,5 @@ a:hover {
align-items: center; align-items: center;
color: hsl(0deg 0% 100% / 20%); color: hsl(0deg 0% 100% / 20%);
grid-gap: 10px; grid-gap: 10px;
cursor: pointer;
} }

58
src/types/errors.ts Normal file
View 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)
}
}

View File

@ -6,3 +6,4 @@ export * from './zap'
export * from './blog' export * from './blog'
export * from './category' export * from './category'
export * from './popup' export * from './popup'
export * from './errors'

View File

@ -2,9 +2,9 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,