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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
-
- )
-}
-
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,