diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c9537..efd1fcc 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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 } + ] + } } diff --git a/package-lock.json b/package-lock.json index ecdf918..1c347a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0bbfdcf..8ac96df 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Inputs/Error.tsx b/src/components/Inputs/Error.tsx new file mode 100644 index 0000000..ce22262 --- /dev/null +++ b/src/components/Inputs/Error.tsx @@ -0,0 +1,14 @@ +type InputErrorProps = { + message: string +} + +export const InputError = ({ message }: InputErrorProps) => { + if (!message) return null + + return ( +
+
+

{message}

+
+ ) +} diff --git a/src/components/Inputs/ImageUpload.tsx b/src/components/Inputs/ImageUpload.tsx new file mode 100644 index 0000000..eba3e49 --- /dev/null +++ b/src/components/Inputs/ImageUpload.tsx @@ -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( + 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 ( +
+ +
+ + + {dropzoneLabel} +
e.stopPropagation()} + > +
+ +
+ {MEDIA_OPTIONS.map((mo) => { + return ( +
+ {mo.name} +
+ ) + })} +
+
+
+
+
+ ) + } +) diff --git a/src/components/Inputs/MediaInputError.module.scss b/src/components/Inputs/MediaInputError.module.scss new file mode 100644 index 0000000..29853ea --- /dev/null +++ b/src/components/Inputs/MediaInputError.module.scss @@ -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; +} diff --git a/src/components/Inputs/MediaInputError.tsx b/src/components/Inputs/MediaInputError.tsx new file mode 100644 index 0000000..6d76baa --- /dev/null +++ b/src/components/Inputs/MediaInputError.tsx @@ -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 ( +
+

+ +

+ {errors && ( +
+
+ {errors.map((e) => { + return typeof e === 'string' ? ( +
+ {e} +
+ ) : ( +
+ {e.message} +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/src/components/Inputs/MediaInputPopover.module.scss b/src/components/Inputs/MediaInputPopover.module.scss new file mode 100644 index 0000000..0eab1d8 --- /dev/null +++ b/src/components/Inputs/MediaInputPopover.module.scss @@ -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; +} diff --git a/src/components/Inputs/MediaInputPopover.tsx b/src/components/Inputs/MediaInputPopover.tsx new file mode 100644 index 0000000..062ca4b --- /dev/null +++ b/src/components/Inputs/MediaInputPopover.tsx @@ -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) => ( + + )), + [acceptedFiles] + ) + const fileRejectionItems = useMemo(() => { + const id = `errors-${uuid}` + return ( +
+ {fileRejections.map(({ file, errors }, index) => ( + + ))} +
+ ) + }, [fileRejections, uuid]) + + if (acceptedFiles.length === 0 && fileRejections.length === 0) return null + + return ( + + +
+ {acceptedFiles.length > 0 ? ( + + + + ) : ( + + + + )} +
+
+ + +
+
+

Selected files

+
+ +
+ + + +
+
+
+
+ {acceptedFileItems} + {fileRejectionItems} +
+
+
+
+ ) +} diff --git a/src/components/Inputs/Success.tsx b/src/components/Inputs/Success.tsx new file mode 100644 index 0000000..40cd216 --- /dev/null +++ b/src/components/Inputs/Success.tsx @@ -0,0 +1,14 @@ +type InputSuccessProps = { + message: string +} + +export const InputSuccess = ({ message }: InputSuccessProps) => { + if (!message) return null + + return ( +
+
+

{message}

+
+ ) +} diff --git a/src/components/Inputs.tsx b/src/components/Inputs/index.tsx similarity index 69% rename from src/components/Inputs.tsx rename to src/components/Inputs/index.tsx index 530fe53..1658d59 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs/index.tsx @@ -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 ( -
-
-

{message}

-
- ) -} - interface CheckboxFieldProps { label: string name: string @@ -157,3 +144,63 @@ export const CheckboxFieldUncontrolled = ({ ) + +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) => { + onInputChange(name, e.currentTarget.value) + }, + [name, onInputChange] + ) + + const handleFileChange = useCallback( + (values: string[]) => { + onInputChange(name, values[0]) + }, + [name, onInputChange] + ) + + return ( +
+ + {typeof description !== 'undefined' && ( +

{description}

+ )} + + + + + {error && } +
+ ) + } +) diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 43c030b..914db43 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -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 = () => { /> - {

- We recommend to upload images to https://imgur.com/upload + We recommend to upload images to {MEDIA_OPTIONS[0].host}

+ + { + setFormState((prevState) => ({ + ...prevState, + screenshotsUrls: Array.from( + new Set([ + ...prevState.screenshotsUrls.filter((url) => url), + ...values + ]) + ) + })) + }} + /> + {formState.screenshotsUrls.map((url, index) => ( 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/pages/write/index.tsx b/src/pages/write/index.tsx index 2bd4bb0..fe4f586 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -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(null) const editorRef = useRef(null) + const [showConfirmPopup, setShowConfirmPopup] = useState(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 /> - setImage(value)} + placeholder='Image URL' /> .uploadBoxMainInside { +.uploadBoxMain:hover { padding: 5px; } @@ -729,4 +736,5 @@ a:hover { align-items: center; color: hsl(0deg 0% 100% / 20%); grid-gap: 10px; -} \ No newline at end of file + cursor: pointer; +} 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' diff --git a/tsconfig.app.json b/tsconfig.app.json index a4e84c2..26c0fc2 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -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,