diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx deleted file mode 100644 index 530fe53..0000000 --- a/src/components/Inputs.tsx +++ /dev/null @@ -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 - ) => { - onChange(name, e.target.value) - } - - return ( -
- - {description &&

{description}

} - {type === 'textarea' ? ( - - ) : ( - - )} - {error && } -
- ) - } -) - -type InputErrorProps = { - message: string -} - -export const InputError = ({ message }: InputErrorProps) => { - if (!message) return null - - return ( -
-
-

{message}

-
- ) -} - -interface CheckboxFieldProps { - label: string - name: string - isChecked: boolean - handleChange: (e: React.ChangeEvent) => void - type?: 'default' | 'stylized' -} - -export const CheckboxField = React.memo( - ({ - label, - name, - isChecked, - handleChange, - type = 'default' - }: CheckboxFieldProps) => ( -
- - -
- ) -) - -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) => ( -
- - {description &&

{description}

} - - {error && } -
-) - -interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> { - label: string -} - -export const CheckboxFieldUncontrolled = ({ - label, - ...rest -}: CheckboxFieldUncontrolledProps) => ( -
- - -
-) diff --git a/src/components/Inputs/index.tsx b/src/components/Inputs/index.tsx new file mode 100644 index 0000000..61c6774 --- /dev/null +++ b/src/components/Inputs/index.tsx @@ -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 + ) => { + onChange(name, e.target.value) + } + + return ( +
+ + {description &&

{description}

} + {type === 'textarea' ? ( + + ) : ( + + )} + {error && } +
+ ) + } +) + +interface CheckboxFieldProps { + label: string + name: string + isChecked: boolean + handleChange: (e: React.ChangeEvent) => void + type?: 'default' | 'stylized' +} + +export const CheckboxField = React.memo( + ({ + label, + name, + isChecked, + handleChange, + type = 'default' + }: CheckboxFieldProps) => ( +
+ + +
+ ) +) + +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) => ( +
+ + {description &&

{description}

} + + {error && } +
+) + +interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> { + label: string +} + +export const CheckboxFieldUncontrolled = ({ + label, + ...rest +}: CheckboxFieldUncontrolledProps) => ( +
+ + +
+) + +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( + MEDIA_OPTIONS[0] + ) + const handleOptionChange = useCallback( + (mo: MediaOption) => () => { + setMediaOption(mo) + }, + [] + ) + const handleChange = useCallback( + (e: React.ChangeEvent) => { + 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 ( +
+ + {typeof description !== 'undefined' && ( +

{description}

+ )} + +
+ +
+ + + {dropzoneLabel} +
e.stopPropagation()} + > +
+ +
+ {MEDIA_OPTIONS.map((mo) => { + return ( +
+ {mo.name} +
+ ) + })} +
+
+
+
+
+ + {error && } +
+ ) + } +) diff --git a/src/controllers/image/index.ts b/src/controllers/image/index.ts new file mode 100644 index 0000000..d1d362d --- /dev/null +++ b/src/controllers/image/index.ts @@ -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 +} +export type MediaStrategy = Omit + +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 + + 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) + } + } +} diff --git a/src/controllers/image/nostrcheck-server.ts b/src/controllers/image/nostrcheck-server.ts new file mode 100644 index 0000000..fec22f3 --- /dev/null +++ b/src/controllers/image/nostrcheck-server.ts @@ -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( + 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) + }) + } + } +} diff --git a/src/controllers/image/route96.ts b/src/controllers/image/route96.ts new file mode 100644 index 0000000..9451015 --- /dev/null +++ b/src/controllers/image/route96.ts @@ -0,0 +1,7 @@ +import { MediaOperations } from '.' + +export class route96 implements MediaOperations { + post = () => { + throw new Error('route96 post Not implemented.') + } +} diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4e84779..9c81c4c 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1 +1,2 @@ export * from './zap' +export * from './image' diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..520d6f3 --- /dev/null +++ b/src/types/errors.ts @@ -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) + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 7b2f35c..7082027 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,3 +6,4 @@ export * from './zap' export * from './blog' export * from './category' export * from './popup' +export * from './errors'