feat(image): add controller, media services and error handling
This commit is contained in:
parent
e3aab5a5dc
commit
0b2d488bbe
@ -1,159 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import '../styles/styles.css'
|
|
||||||
|
|
||||||
interface InputFieldProps {
|
|
||||||
label: string | React.ReactElement
|
|
||||||
description?: string
|
|
||||||
type?: 'text' | 'textarea'
|
|
||||||
placeholder: string
|
|
||||||
name: string
|
|
||||||
inputMode?: 'url'
|
|
||||||
value: string
|
|
||||||
error?: string
|
|
||||||
onChange: (name: string, value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InputField = React.memo(
|
|
||||||
({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
type = 'text',
|
|
||||||
placeholder,
|
|
||||||
name,
|
|
||||||
inputMode,
|
|
||||||
value,
|
|
||||||
error,
|
|
||||||
onChange
|
|
||||||
}: InputFieldProps) => {
|
|
||||||
const handleChange = (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
onChange(name, e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inputLabelWrapperMain'>
|
|
||||||
<label className='form-label labelMain'>{label}</label>
|
|
||||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
|
||||||
{type === 'textarea' ? (
|
|
||||||
<textarea
|
|
||||||
className='inputMain'
|
|
||||||
placeholder={placeholder}
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
></textarea>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className='inputMain'
|
|
||||||
placeholder={placeholder}
|
|
||||||
name={name}
|
|
||||||
inputMode={inputMode}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{error && <InputError message={error} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
isChecked: boolean
|
|
||||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
type?: 'default' | 'stylized'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CheckboxField = React.memo(
|
|
||||||
({
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
isChecked,
|
|
||||||
handleChange,
|
|
||||||
type = 'default'
|
|
||||||
}: CheckboxFieldProps) => (
|
|
||||||
<div
|
|
||||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
|
|
||||||
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<label htmlFor={name} className='form-label labelMain'>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={name}
|
|
||||||
type='checkbox'
|
|
||||||
className='CheckboxMain'
|
|
||||||
name={name}
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
|
||||||
label: string | React.ReactElement
|
|
||||||
description?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Uncontrolled input component with design classes, label, description and error support
|
|
||||||
*
|
|
||||||
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
|
||||||
* @param label
|
|
||||||
* @param description
|
|
||||||
* @param error
|
|
||||||
*
|
|
||||||
* @see {@link React.ComponentProps<'input'>}
|
|
||||||
*/
|
|
||||||
export const InputFieldUncontrolled = ({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
error,
|
|
||||||
...rest
|
|
||||||
}: InputFieldUncontrolledProps) => (
|
|
||||||
<div className='inputLabelWrapperMain'>
|
|
||||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
|
||||||
<input className='inputMain' {...rest} />
|
|
||||||
{error && <InputError message={error} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CheckboxFieldUncontrolled = ({
|
|
||||||
label,
|
|
||||||
...rest
|
|
||||||
}: CheckboxFieldUncontrolledProps) => (
|
|
||||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
|
||||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input type='checkbox' className='CheckboxMain' {...rest} />
|
|
||||||
</div>
|
|
||||||
)
|
|
299
src/components/Inputs/index.tsx
Normal file
299
src/components/Inputs/index.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
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 { InputError } from './Error'
|
||||||
|
import { MediaInputPopover } from './MediaInputPopover'
|
||||||
|
|
||||||
|
interface InputFieldProps {
|
||||||
|
label: string | React.ReactElement
|
||||||
|
description?: string
|
||||||
|
type?: 'text' | 'textarea'
|
||||||
|
placeholder: string
|
||||||
|
name: string
|
||||||
|
inputMode?: 'url'
|
||||||
|
value: string
|
||||||
|
error?: string
|
||||||
|
onChange: (name: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputField = React.memo(
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
type = 'text',
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
inputMode,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
onChange
|
||||||
|
}: InputFieldProps) => {
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
onChange(name, e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain'>{label}</label>
|
||||||
|
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||||
|
{type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
className='inputMain'
|
||||||
|
placeholder={placeholder}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
></textarea>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className='inputMain'
|
||||||
|
placeholder={placeholder}
|
||||||
|
name={name}
|
||||||
|
inputMode={inputMode}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && <InputError message={error} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CheckboxFieldProps {
|
||||||
|
label: string
|
||||||
|
name: string
|
||||||
|
isChecked: boolean
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
type?: 'default' | 'stylized'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckboxField = React.memo(
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
isChecked,
|
||||||
|
handleChange,
|
||||||
|
type = 'default'
|
||||||
|
}: CheckboxFieldProps) => (
|
||||||
|
<div
|
||||||
|
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
|
||||||
|
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label htmlFor={name} className='form-label labelMain'>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
type='checkbox'
|
||||||
|
className='CheckboxMain'
|
||||||
|
name={name}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||||
|
label: string | React.ReactElement
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Uncontrolled input component with design classes, label, description and error support
|
||||||
|
*
|
||||||
|
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
||||||
|
* @param label
|
||||||
|
* @param description
|
||||||
|
* @param error
|
||||||
|
*
|
||||||
|
* @see {@link React.ComponentProps<'input'>}
|
||||||
|
*/
|
||||||
|
export const InputFieldUncontrolled = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
...rest
|
||||||
|
}: InputFieldUncontrolledProps) => (
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||||
|
<input className='inputMain' {...rest} />
|
||||||
|
{error && <InputError message={error} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckboxFieldUncontrolled = ({
|
||||||
|
label,
|
||||||
|
...rest
|
||||||
|
}: CheckboxFieldUncontrolledProps) => (
|
||||||
|
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||||
|
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface InputFieldWithImageUpload {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
onChange(name, e.target.value)
|
||||||
|
},
|
||||||
|
[name, onChange]
|
||||||
|
)
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain'>{label}</label>
|
||||||
|
{typeof description !== 'undefined' && (
|
||||||
|
<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()} />
|
||||||
|
|
||||||
|
<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'
|
||||||
|
placeholder={placeholder}
|
||||||
|
name={name}
|
||||||
|
inputMode={inputMode}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
{error && <InputError message={error} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
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 './zap'
|
||||||
|
export * from './image'
|
||||||
|
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 './blog'
|
||||||
export * from './category'
|
export * from './category'
|
||||||
export * from './popup'
|
export * from './popup'
|
||||||
|
export * from './errors'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user