From 31cd62588663a0969d9356ebf67f3507c8178320 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 09:41:20 +0100 Subject: [PATCH 01/10] feat(image): add dropzone package --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 40 insertions(+) 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", From e3aab5a5dc9802bb62ae1822ae1254c79c5bb4c6 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 09:43:46 +0100 Subject: [PATCH 02/10] build: bump to es2022 --- .eslintrc.cjs | 10 +++++----- tsconfig.app.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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/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, From 0b2d488bbe749d052de340c844f18469df43e6c0 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 09:46:28 +0100 Subject: [PATCH 03/10] feat(image): add controller, media services and error handling --- src/components/Inputs.tsx | 159 ----------- src/components/Inputs/index.tsx | 299 +++++++++++++++++++++ src/controllers/image/index.ts | 74 +++++ src/controllers/image/nostrcheck-server.ts | 162 +++++++++++ src/controllers/image/route96.ts | 7 + src/controllers/index.ts | 1 + src/types/errors.ts | 58 ++++ src/types/index.ts | 1 + 8 files changed, 602 insertions(+), 159 deletions(-) delete mode 100644 src/components/Inputs.tsx create mode 100644 src/components/Inputs/index.tsx create mode 100644 src/controllers/image/index.ts create mode 100644 src/controllers/image/nostrcheck-server.ts create mode 100644 src/controllers/image/route96.ts create mode 100644 src/types/errors.ts 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' From b33015cbaf1873b8f13c49e6a7eac3d4851f70fb Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 09:48:30 +0100 Subject: [PATCH 04/10] feat(image): add direct image upload components --- src/components/Inputs/Error.tsx | 14 +++ .../Inputs/MediaInputError.module.scss | 14 +++ src/components/Inputs/MediaInputError.tsx | 64 +++++++++++ .../Inputs/MediaInputPopover.module.scss | 45 ++++++++ src/components/Inputs/MediaInputPopover.tsx | 108 ++++++++++++++++++ src/components/Inputs/Success.tsx | 14 +++ src/styles/styles.css | 18 ++- 7 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 src/components/Inputs/Error.tsx create mode 100644 src/components/Inputs/MediaInputError.module.scss create mode 100644 src/components/Inputs/MediaInputError.tsx create mode 100644 src/components/Inputs/MediaInputPopover.module.scss create mode 100644 src/components/Inputs/MediaInputPopover.tsx create mode 100644 src/components/Inputs/Success.tsx 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/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..1f98de6 --- /dev/null +++ b/src/components/Inputs/MediaInputPopover.tsx @@ -0,0 +1,108 @@ +import * as Popover from '@radix-ui/react-popover' +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 { + name: string + acceptedFiles: readonly FileWithPath[] + fileRejections: readonly FileRejection[] +} + +export const MediaInputPopover = ({ + name, + acceptedFiles, + fileRejections +}: MediaInputPopoverProps) => { + const acceptedFileItems = useMemo( + () => + acceptedFiles.map((file) => ( + + )), + [acceptedFiles] + ) + const fileRejectionItems = useMemo(() => { + const id = `${name}-errors` + return ( +
+ {fileRejections.map(({ file, errors }, index) => ( + + ))} +
+ ) + }, [fileRejections, name]) + + 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/styles/styles.css b/src/styles/styles.css index de6f31d..d4b0247 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -661,6 +661,7 @@ a:hover { padding-right: 50px; } +.successMain, .errorMain { width: 100%; border-radius: 10px; @@ -671,13 +672,17 @@ a:hover { flex-direction: row; grid-gap: 10px; } - +.successMainColor, .errorMainColor { width: 5px; border-radius: 2px; +} +.errorMainColor { background: tomato; } - +.successMainColor { + background: #60ae60; +} .errorMainText { } @@ -709,12 +714,14 @@ a:hover { .uploadBoxMain { background: hsl(0deg 0% 0% / 10%); border-radius: 10px; - height: 10px; + height: 150px; padding: 10px; border: solid 1px hsl(0deg 0% 100% / 5%); + transition: padding ease-in-out 0.4s; + position: relative; } -.uploadBoxMain:hover > .uploadBoxMainInside { +.uploadBoxMain:hover { padding: 5px; } @@ -729,4 +736,5 @@ a:hover { align-items: center; color: hsl(0deg 0% 100% / 20%); grid-gap: 10px; -} \ No newline at end of file + cursor: pointer; +} From 4c410be9ba115a8a42f73500d118678ae86a74f8 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 09:49:02 +0100 Subject: [PATCH 05/10] feat(image): use image upload field --- src/components/ModForm.tsx | 9 +++++---- src/pages/write/index.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 98cdc22..4f852c1 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -23,11 +23,13 @@ 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' interface GameOption { value: string @@ -220,10 +222,9 @@ export const ModForm = () => { /> - { const userState = useAppSelector((state) => state.user) From 9fd1aca99c349131c5ddd75cd6a851236b9eb8a8 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 10:04:37 +0100 Subject: [PATCH 06/10] feat(image): use image upload field in blog --- src/pages/write/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index fd9d12c..36d415e 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -7,7 +7,8 @@ import { } from 'react-router-dom' import { CheckboxFieldUncontrolled, - InputFieldUncontrolled + InputFieldUncontrolled, + InputFieldWithImageUpload } from '../../components/Inputs' import { ProfileSection } from '../../components/ProfileSection' import { useAppSelector } from '../../hooks' @@ -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 [featuredImageUrl, setfeaturedImageUrl] = useState(blog?.image || '') + const formRef = useRef(null) const editorRef = useRef(null) + const [showConfirmPopup, setShowConfirmPopup] = useState(false) const handleReset = () => { setShowConfirmPopup(true) @@ -97,12 +101,14 @@ export const WritePage = () => { readOnly /> - setfeaturedImageUrl(value)} /> Date: Tue, 7 Jan 2025 12:40:27 +0100 Subject: [PATCH 07/10] feat(image): multiple files upload --- src/components/Inputs/ImageUpload.tsx | 116 ++++++++++++++++++ src/components/Inputs/MediaInputPopover.tsx | 8 +- src/components/Inputs/index.tsx | 125 +++----------------- src/components/ModForm.tsx | 25 +++- src/pages/write/index.tsx | 2 +- 5 files changed, 159 insertions(+), 117 deletions(-) create mode 100644 src/components/Inputs/ImageUpload.tsx diff --git a/src/components/Inputs/ImageUpload.tsx b/src/components/Inputs/ImageUpload.tsx new file mode 100644 index 0000000..717183a --- /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)...' + : 'TODO' + : '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/MediaInputPopover.tsx b/src/components/Inputs/MediaInputPopover.tsx index 1f98de6..062ca4b 100644 --- a/src/components/Inputs/MediaInputPopover.tsx +++ b/src/components/Inputs/MediaInputPopover.tsx @@ -1,4 +1,5 @@ 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' @@ -6,16 +7,15 @@ import { InputSuccess } from './Success' import styles from './MediaInputPopover.module.scss' interface MediaInputPopoverProps { - name: string acceptedFiles: readonly FileWithPath[] fileRejections: readonly FileRejection[] } export const MediaInputPopover = ({ - name, acceptedFiles, fileRejections }: MediaInputPopoverProps) => { + const uuid = useMemo(() => uuidv4(), []) const acceptedFileItems = useMemo( () => acceptedFiles.map((file) => ( @@ -27,7 +27,7 @@ export const MediaInputPopover = ({ [acceptedFiles] ) const fileRejectionItems = useMemo(() => { - const id = `${name}-errors` + const id = `errors-${uuid}` return (
) - }, [fileRejections, name]) + }, [fileRejections, uuid]) if (acceptedFiles.length === 0 && fileRejections.length === 0) return null diff --git a/src/components/Inputs/index.tsx b/src/components/Inputs/index.tsx index 61c6774..1658d59 100644 --- a/src/components/Inputs/index.tsx +++ b/src/components/Inputs/index.tsx @@ -1,15 +1,7 @@ -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 React, { useCallback } from 'react' import { InputError } from './Error' -import { MediaInputPopover } from './MediaInputPopover' +import { ImageUpload } from './ImageUpload' +import '../../styles/styles.css' interface InputFieldProps { label: string | React.ReactElement @@ -153,83 +145,40 @@ export const CheckboxFieldUncontrolled = ({
) -interface InputFieldWithImageUpload { +interface InputFieldWithImageUploadProps { 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 + onInputChange: (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) - }, - [] - ) + onInputChange + }: InputFieldWithImageUploadProps) => { const handleChange = useCallback( (e: React.ChangeEvent) => { - onChange(name, e.target.value) + onInputChange(name, e.currentTarget.value) }, - [name, onChange] + [name, onInputChange] ) - 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] + const handleFileChange = useCallback( + (values: string[]) => { + onInputChange(name, values[0]) + }, + [name, onInputChange] ) return ( @@ -239,50 +188,8 @@ export const InputFieldWithImageUpload = React.memo(

{description}

)} -
- -
- + - {dropzoneLabel} -
e.stopPropagation()} - > -
- -
- {MEDIA_OPTIONS.map((mo) => { - return ( -
- {mo.name} -
- ) - })} -
-
-
-
-
{ const [formState, setFormState] = useState( initializeFormState(mod) ) + console.log(`[debug] screenshots`, formState.screenshotsUrls) const editorRef = useRef(null) useEffect(() => { @@ -230,7 +232,7 @@ export const ModForm = () => { name='featuredImageUrl' value={formState.featuredImageUrl} error={formErrors?.featuredImageUrl} - onChange={handleInputChange} + onInputChange={handleInputChange} /> {

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

+ + { + // values.forEach((screenshotUrl) => addScreenshotUrl()) + setFormState((prevState) => ({ + ...prevState, + screenshotsUrls: Array.from( + new Set([ + ...prevState.screenshotsUrls.filter((url) => url), + ...values + ]) + ) + })) + }} + /> + {formState.screenshotsUrls.map((url, index) => ( diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index 36d415e..9f21d31 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -108,7 +108,7 @@ export const WritePage = () => { name='featuredImageUrl' value={featuredImageUrl} error={formErrors?.image} - onChange={(_, value) => setfeaturedImageUrl(value)} + onInputChange={(_, value) => setfeaturedImageUrl(value)} /> Date: Tue, 7 Jan 2025 14:31:19 +0100 Subject: [PATCH 08/10] fix(image): bad image url input field name --- src/pages/write/index.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index 9f21d31..fe4f586 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -30,7 +30,7 @@ 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 [featuredImageUrl, setfeaturedImageUrl] = useState(blog?.image || '') + const [image, setImage] = useState(blog?.image || '') const formRef = useRef(null) const editorRef = useRef(null) @@ -45,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) @@ -103,12 +106,12 @@ export const WritePage = () => { setfeaturedImageUrl(value)} + onInputChange={(_, value) => setImage(value)} + placeholder='Image URL' /> Date: Tue, 7 Jan 2025 16:39:23 +0100 Subject: [PATCH 09/10] chore: code cleanup --- src/components/Inputs/ImageUpload.tsx | 2 +- src/components/ModForm.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Inputs/ImageUpload.tsx b/src/components/Inputs/ImageUpload.tsx index 717183a..eba3e49 100644 --- a/src/components/Inputs/ImageUpload.tsx +++ b/src/components/Inputs/ImageUpload.tsx @@ -66,7 +66,7 @@ export const ImageUpload = React.memo( ? 'Drop the files here...' : isDragReject ? 'Drop the files here (one more more unsupported types)...' - : 'TODO' + : 'Drop the files here...' : 'Click or drag files here', [isDragAccept, isDragActive, isDragReject, isFileDialogActive] ) diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 5c67b9d..795f53e 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -48,7 +48,6 @@ export const ModForm = () => { const [formState, setFormState] = useState( initializeFormState(mod) ) - console.log(`[debug] screenshots`, formState.screenshotsUrls) const editorRef = useRef(null) useEffect(() => { From 8205d4ac3efc9dedbbdcb1bec0aebb8849632a4d Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 7 Jan 2025 19:29:16 +0100 Subject: [PATCH 10/10] refactor: remove comment --- src/components/ModForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 795f53e..914db43 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -301,7 +301,6 @@ export const ModForm = () => { { - // values.forEach((screenshotUrl) => addScreenshotUrl()) setFormState((prevState) => ({ ...prevState, screenshotsUrls: Array.from(