diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index 95b4f48..aaf8162 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -330,7 +330,7 @@ const MenuBarButton = ({ ) interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> { - label: string + label: string | React.ReactElement description?: string error?: string } diff --git a/src/components/Markdown/Editor.tsx b/src/components/Markdown/Editor.tsx new file mode 100644 index 0000000..727e008 --- /dev/null +++ b/src/components/Markdown/Editor.tsx @@ -0,0 +1,131 @@ +import { + BlockTypeSelect, + BoldItalicUnderlineToggles, + codeBlockPlugin, + CodeToggle, + CreateLink, + directivesPlugin, + headingsPlugin, + imagePlugin, + InsertCodeBlock, + InsertImage, + InsertTable, + InsertThematicBreak, + linkDialogPlugin, + linkPlugin, + listsPlugin, + ListsToggle, + markdownShortcutPlugin, + MDXEditor, + MDXEditorMethods, + MDXEditorProps, + quotePlugin, + Separator, + StrikeThroughSupSubToggles, + tablePlugin, + thematicBreakPlugin, + toolbarPlugin, + UndoRedo +} from '@mdxeditor/editor' +import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor' +import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor' +import { YouTubeButton } from './YoutubeButton' +import '@mdxeditor/editor/style.css' +import '../../styles/mdxEditor.scss' +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef +} from 'react' + +export interface EditorRef { + setMarkdown: (md: string) => void +} +interface EditorProps extends MDXEditorProps {} +/** + * The editor component is small wrapper (`forwardRef`) around {@link MDXEditor MDXEditor} that sets up the toolbars and plugins, and requires `markdown` and `onChange`. + * To reset editor markdown it's required to pass the {@link EditorRef EditorRef}. + * + * Extends {@link MDXEditorProps MDXEditorProps} + * + * **Important**: the markdown is not a state, but an _initialState_ and is not "controlled". + * All updates are handled with onChange and will not be reflected on markdown prop. + * This component should never re-render if used correctly. + * @see https://mdxeditor.dev/editor/docs/getting-started#basic-usage + */ +export const Editor = React.memo( + forwardRef(({ markdown, onChange, ...rest }, ref) => { + const editorRef = useRef(null) + const setMarkdown = useCallback((md: string) => { + editorRef.current?.setMarkdown(md) + }, []) + useImperativeHandle(ref, () => ({ setMarkdown })) + const plugins = useMemo( + () => [ + toolbarPlugin({ + toolbarContents: () => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + ) + }), + headingsPlugin(), + quotePlugin(), + imagePlugin(), + tablePlugin(), + linkPlugin(), + linkDialogPlugin(), + listsPlugin(), + thematicBreakPlugin(), + directivesPlugin({ + directiveDescriptors: [YoutubeDirectiveDescriptor] + }), + markdownShortcutPlugin(), + // HACK: due to a bug with shortcut interaction shortcut for code block is disabled + // Editor freezes if you type in ```word and put a space in between ``` word + codeBlockPlugin({ + defaultCodeBlockLanguage: '', + codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor] + }) + ], + [] + ) + + return ( + + ) + }), + () => true +) diff --git a/src/components/editor/PlainTextCodeEditorDescriptor.tsx b/src/components/Markdown/PlainTextCodeEditorDescriptor.tsx similarity index 95% rename from src/components/editor/PlainTextCodeEditorDescriptor.tsx rename to src/components/Markdown/PlainTextCodeEditorDescriptor.tsx index c6ed160..1dd48cb 100644 --- a/src/components/editor/PlainTextCodeEditorDescriptor.tsx +++ b/src/components/Markdown/PlainTextCodeEditorDescriptor.tsx @@ -35,7 +35,8 @@ export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = { if (event.key === 'Backspace' || event.key === 'Delete') { if (codeRef.current?.textContent === '') { parentEditor.update(() => { - lexicalNode.remove(false) + lexicalNode.selectNext() + lexicalNode.remove() }) } } diff --git a/src/components/Markdown/Viewer.tsx b/src/components/Markdown/Viewer.tsx new file mode 100644 index 0000000..2a22e75 --- /dev/null +++ b/src/components/Markdown/Viewer.tsx @@ -0,0 +1,13 @@ +import DOMPurify from 'dompurify' +import { marked } from 'marked' + +interface ViewerProps { + markdown: string +} + +export const Viewer = ({ markdown }: ViewerProps) => { + const html = DOMPurify.sanitize(marked.parse(markdown, { async: false })) + return ( +
+ ) +} diff --git a/src/components/editor/YoutubeButton.tsx b/src/components/Markdown/YoutubeButton.tsx similarity index 100% rename from src/components/editor/YoutubeButton.tsx rename to src/components/Markdown/YoutubeButton.tsx diff --git a/src/components/editor/YoutubeDirectiveDescriptor.tsx b/src/components/Markdown/YoutubeDirectiveDescriptor.tsx similarity index 100% rename from src/components/editor/YoutubeDirectiveDescriptor.tsx rename to src/components/Markdown/YoutubeDirectiveDescriptor.tsx diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 1db8b96..19d0326 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -1,5 +1,4 @@ import _ from 'lodash' -import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import React, { Fragment, useCallback, @@ -8,80 +7,51 @@ import React, { useRef, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { toast } from 'react-toastify' -import { FixedSizeList } from 'react-window' -import { v4 as uuidv4 } from 'uuid' -import { T_TAG_VALUE } from '../constants' -import { useAppSelector, useGames, useNDKContext } from '../hooks' -import { appRoutes, getModPageRoute } from '../routes' -import '../styles/styles.css' -import { DownloadUrl, ModDetails, ModFormState } from '../types' import { - initializeFormState, - isReachable, - isValidImageUrl, - isValidUrl, - log, - LogType, - now -} from '../utils' + useActionData, + useLoaderData, + useNavigation, + useSubmit +} from 'react-router-dom' +import { FixedSizeList } from 'react-window' +import { useGames } from '../hooks' +import '../styles/styles.css' +import { + DownloadUrl, + FormErrors, + ModFormState, + ModPageLoaderResult +} from '../types' +import { initializeFormState } from '../utils' import { CheckboxField, InputError, InputField } from './Inputs' -import { LoadingSpinner } from './LoadingSpinner' -import { NDKEvent } from '@nostr-dev-kit/ndk' import { OriginalAuthor } from './OriginalAuthor' import { CategoryAutocomplete } from './CategoryAutocomplete' import { AlertPopup } from './AlertPopup' - -interface FormErrors { - game?: string - title?: string - body?: string - featuredImageUrl?: string - summary?: string - nsfw?: string - screenshotsUrls?: string[] - tags?: string - downloadUrls?: string[] - author?: string - originalAuthor?: string -} +import { Editor, EditorRef } from './Markdown/Editor' +import TurndownService from 'turndown' +import DOMPurify from 'dompurify' interface GameOption { value: string label: string } -type ModFormProps = { - existingModData?: ModDetails -} - -export const ModForm = ({ existingModData }: ModFormProps) => { - const location = useLocation() - const navigate = useNavigate() - const { ndk, publish } = useNDKContext() +export const ModForm = () => { + const data = useLoaderData() as ModPageLoaderResult + const mod = data?.mod + const formErrors = useActionData() as FormErrors + const navigation = useNavigation() + const submit = useSubmit() const games = useGames() - const userState = useAppSelector((state) => state.user) - - const [isPublishing, setIsPublishing] = useState(false) const [gameOptions, setGameOptions] = useState([]) const [formState, setFormState] = useState( - initializeFormState() + initializeFormState(mod) ) - const [formErrors, setFormErrors] = useState({}) - - useEffect(() => { - if (location.pathname === appRoutes.submitMod) { - // Only trigger when the pathname changes to submit-mod - setFormState(initializeFormState()) - } - }, [location.pathname]) - - useEffect(() => { - if (existingModData) { - setFormState(initializeFormState(existingModData)) - } - }, [existingModData]) + const editorRef = useRef(null) + const sanitized = DOMPurify.sanitize(formState.body) + const turndown = new TurndownService() + turndown.keep(['sup', 'sub']) + const markdown = turndown.turndown(sanitized) useEffect(() => { const options = games.map((game) => ({ @@ -188,221 +158,39 @@ export const ModForm = ({ existingModData }: ModFormProps) => { if (!confirm) return // Editing - if (existingModData) { + if (mod) { + const initial = initializeFormState(mod) + + // Reset editor + editorRef.current?.setMarkdown(initial.body) + // Reset fields to the original existing data - setFormState(initializeFormState(existingModData)) + setFormState(initial) return } // New - set form state to the initial (clear form state) setFormState(initializeFormState()) } - - const handlePublish = async () => { - setIsPublishing(true) - - let hexPubkey: string - - if (userState.auth && userState.user?.pubkey) { - hexPubkey = userState.user.pubkey as string - } else { - hexPubkey = (await window.nostr?.getPublicKey()) as string - } - - if (!hexPubkey) { - toast.error('Could not get pubkey') - return - } - - if (!(await validateState())) { - setIsPublishing(false) - return - } - - const uuid = formState.dTag || uuidv4() - const currentTimeStamp = now() - - const aTag = - formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` - - const tags = [ - ['d', uuid], - ['a', aTag], - ['r', formState.rTag], - ['t', T_TAG_VALUE], - [ - 'published_at', - existingModData - ? existingModData.published_at.toString() - : currentTimeStamp.toString() - ], - ['game', formState.game], - ['title', formState.title], - ['featuredImageUrl', formState.featuredImageUrl], - ['summary', formState.summary], - ['nsfw', formState.nsfw.toString()], - ['repost', formState.repost.toString()], - ['screenshotsUrls', ...formState.screenshotsUrls], - ['tags', ...formState.tags.split(',')], - [ - 'downloadUrls', - ...formState.downloadUrls.map((downloadUrl) => - JSON.stringify(downloadUrl) - ) - ] - ] - if (formState.repost && formState.originalAuthor) { - tags.push(['originalAuthor', formState.originalAuthor]) - } - - // Prepend com.degmods to avoid leaking categories to 3rd party client's search - // Add hierarchical namespaces labels - if (formState.LTags.length > 0) { - for (let i = 0; i < formState.LTags.length; i++) { - tags.push(['L', `com.degmods:${formState.LTags[i]}`]) - } - } - - // Add category labels - if (formState.lTags.length > 0) { - for (let i = 0; i < formState.lTags.length; i++) { - tags.push(['l', `com.degmods:${formState.lTags[i]}`]) - } - } - - const unsignedEvent: UnsignedEvent = { - kind: kinds.ClassifiedListing, - created_at: currentTimeStamp, - pubkey: hexPubkey, - content: formState.body, - tags - } - - const signedEvent = await window.nostr - ?.signEvent(unsignedEvent) - .then((event) => event as Event) - .catch((err) => { - toast.error('Failed to sign the event!') - log(true, LogType.Error, 'Failed to sign the event!', err) - return null - }) - - if (!signedEvent) { - setIsPublishing(false) - return - } - - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) - - // Handle cases where publishing failed or succeeded - if (publishedOnRelays.length === 0) { - toast.error('Failed to publish event on any relay') - } else { - toast.success( - `Event published successfully to the following relays\n\n${publishedOnRelays.join( - '\n' - )}` - ) - - const naddr = nip19.naddrEncode({ - identifier: aTag, - pubkey: signedEvent.pubkey, - kind: signedEvent.kind, - relays: publishedOnRelays - }) - - navigate(getModPageRoute(naddr)) - } - - setIsPublishing(false) - } - - const validateState = async (): Promise => { - const errors: FormErrors = {} - - if (formState.game === '') { - errors.game = 'Game field can not be empty' - } - - if (formState.title === '') { - errors.title = 'Title field can not be empty' - } - - if (formState.body === '') { - errors.body = 'Body field can not be empty' - } - - if (formState.featuredImageUrl === '') { - errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty' - } else if ( - !isValidImageUrl(formState.featuredImageUrl) || - !(await isReachable(formState.featuredImageUrl)) - ) { - errors.featuredImageUrl = - 'FeaturedImageUrl must be a valid and reachable image URL' - } - - if (formState.summary === '') { - errors.summary = 'Summary field can not be empty' - } - - if (formState.screenshotsUrls.length === 0) { - errors.screenshotsUrls = ['Required at least one screenshot url'] - } else { - for (let i = 0; i < formState.screenshotsUrls.length; i++) { - const url = formState.screenshotsUrls[i] - if ( - !isValidUrl(url) || - !isValidImageUrl(url) || - !(await isReachable(url)) - ) { - if (!errors.screenshotsUrls) - errors.screenshotsUrls = Array(formState.screenshotsUrls.length) - - errors.screenshotsUrls![i] = - 'All screenshot URLs must be valid and reachable image URLs' - } - } - } - - if ( - formState.repost && - (!formState.originalAuthor || formState.originalAuthor === '') - ) { - errors.originalAuthor = 'Original author field can not be empty' - } - - if (formState.tags === '') { - errors.tags = 'Tags field can not be empty' - } - - if (formState.downloadUrls.length === 0) { - errors.downloadUrls = ['Required at least one download url'] - } else { - for (let i = 0; i < formState.downloadUrls.length; i++) { - const downloadUrl = formState.downloadUrls[i] - if (!isValidUrl(downloadUrl.url)) { - if (!errors.downloadUrls) - errors.downloadUrls = Array(formState.downloadUrls.length) - - errors.downloadUrls![i] = 'Download url must be valid and reachable' - } - } - } - - setFormErrors(errors) - - return Object.keys(errors).length === 0 + const handlePublish = () => { + submit(JSON.stringify(formState), { + method: mod ? 'put' : 'post', + encType: 'application/json' + }) } return ( - <> - {isPublishing && } +
{ + e.preventDefault() + handlePublish() + }} + > @@ -411,19 +199,32 @@ export const ModForm = ({ existingModData }: ModFormProps) => { placeholder='Return the banana mod' name='title' value={formState.title} - error={formErrors.title} + error={formErrors?.title} onChange={handleInputChange} /> - +
+ +
+ { + handleInputChange('body', md) + }} + /> +
+ {typeof formErrors?.body !== 'undefined' && ( + + )} + +
{ placeholder='Image URL' name='featuredImageUrl' value={formState.featuredImageUrl} - error={formErrors.featuredImageUrl} + error={formErrors?.featuredImageUrl} onChange={handleInputChange} /> { placeholder='This is a quick description of my mod' name='summary' value={formState.summary} - error={formErrors.summary} + error={formErrors?.summary} onChange={handleInputChange} /> { placeholder="Original author's name, npub or nprofile" name='originalAuthor' value={formState.originalAuthor || ''} - error={formErrors.originalAuthor || ''} + error={formErrors?.originalAuthor} onChange={handleInputChange} /> @@ -508,16 +309,16 @@ export const ModForm = ({ existingModData }: ModFormProps) => { onUrlChange={handleScreenshotUrlChange} onRemove={removeScreenshotUrl} /> - {formErrors.screenshotsUrls && - formErrors.screenshotsUrls[index] && ( - + {formErrors?.screenshotsUrls && + formErrors?.screenshotsUrls[index] && ( + )} ))} {formState.screenshotsUrls.length === 0 && - formErrors.screenshotsUrls && - formErrors.screenshotsUrls[0] && ( - + formErrors?.screenshotsUrls && + formErrors?.screenshotsUrls[0] && ( + )} { placeholder='Tags' name='tags' value={formState.tags} - error={formErrors.tags} + error={formErrors?.tags} onChange={handleInputChange} /> { onUrlChange={handleDownloadUrlChange} onRemove={removeDownloadUrl} /> - {formErrors.downloadUrls && formErrors.downloadUrls[index] && ( - + {formErrors?.downloadUrls && formErrors?.downloadUrls[index] && ( + )} ))} {formState.downloadUrls.length === 0 && - formErrors.downloadUrls && - formErrors.downloadUrls[0] && ( - + formErrors?.downloadUrls && + formErrors?.downloadUrls[0] && ( + )}
@@ -589,17 +390,20 @@ export const ModForm = ({ existingModData }: ModFormProps) => { className='btn btnMain' type='button' onClick={handleReset} - disabled={isPublishing} + disabled={ + navigation.state === 'loading' || navigation.state === 'submitting' + } > - {existingModData ? 'Reset' : 'Clear fields'} + {mod ? 'Reset' : 'Clear fields'}
{showConfirmPopup && ( @@ -608,13 +412,13 @@ export const ModForm = ({ existingModData }: ModFormProps) => { handleClose={() => setShowConfirmPopup(false)} header={'Are you sure?'} label={ - existingModData + mod ? `Are you sure you want to clear all changes?` : `Are you sure you want to clear all field data?` } /> )} - + ) } type DownloadUrlFieldsProps = { diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx deleted file mode 100644 index d7a96de..0000000 --- a/src/components/editor/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - BlockTypeSelect, - BoldItalicUnderlineToggles, - codeBlockPlugin, - CodeToggle, - CreateLink, - directivesPlugin, - headingsPlugin, - imagePlugin, - InsertCodeBlock, - InsertImage, - InsertTable, - InsertThematicBreak, - linkDialogPlugin, - linkPlugin, - listsPlugin, - ListsToggle, - markdownShortcutPlugin, - MDXEditor, - MDXEditorProps, - quotePlugin, - Separator, - StrikeThroughSupSubToggles, - tablePlugin, - thematicBreakPlugin, - toolbarPlugin, - UndoRedo -} from '@mdxeditor/editor' -import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor' -import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor' -import { YouTubeButton } from './YoutubeButton' -import '@mdxeditor/editor/style.css' -import '../../styles/mdxEditor.scss' - -interface EditorProps extends MDXEditorProps {} - -export const Editor = ({ - readOnly, - markdown, - onChange, - ...rest -}: EditorProps) => { - const plugins = [ - directivesPlugin({ directiveDescriptors: [YoutubeDirectiveDescriptor] }), - codeBlockPlugin({ - defaultCodeBlockLanguage: '', - codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor] - }), - headingsPlugin(), - quotePlugin(), - imagePlugin(), - tablePlugin(), - linkPlugin(), - linkDialogPlugin(), - listsPlugin(), - thematicBreakPlugin(), - markdownShortcutPlugin() - ] - - if (!readOnly) { - plugins.push( - toolbarPlugin({ - toolbarContents: () => ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - ) - }) - ) - } - - return ( - - ) -} diff --git a/src/layout/header.tsx b/src/layout/header.tsx index f131dc6..1953ab6 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -91,10 +91,7 @@ export const Header = () => {
- +
{
-
diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 2edec2d..a911877 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -1,9 +1,6 @@ -import Link from '@tiptap/extension-link' -import { EditorContent, useEditor } from '@tiptap/react' -import StarterKit from '@tiptap/starter-kit' import FsLightbox from 'fslightbox-react' import { nip19 } from 'nostr-tools' -import { useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link as ReactRouterLink, useLoaderData, @@ -40,6 +37,9 @@ import { ReportPopup } from 'components/ReportPopup' import { Spinner } from 'components/Spinner' import { RouterLoadingSpinner } from 'components/LoadingSpinner' import { OriginalAuthor } from 'components/OriginalAuthor' +import DOMPurify from 'dompurify' +import TurndownService from 'turndown' +import { Viewer } from 'components/Markdown/Viewer' const MOD_REPORT_REASONS = [ { label: 'Actually CP', key: 'actuallyCP' }, @@ -448,9 +448,17 @@ const Body = ({ repost, originalAuthor }: BodyProps) => { + const COLLAPSED_MAX_SIZE = 250 const postBodyRef = useRef(null) const viewFullPostBtnRef = useRef(null) + const markdown = useMemo(() => { + const sanitized = DOMPurify.sanitize(body) + const turndown = new TurndownService() + turndown.keep(['sup', 'sub']) + return turndown.turndown(sanitized) + }, [body]) + const [lightBoxController, setLightBoxController] = useState({ toggler: false, slide: 1 @@ -463,6 +471,14 @@ const Body = ({ })) } + useEffect(() => { + if (postBodyRef.current) { + if (postBodyRef.current.scrollHeight <= COLLAPSED_MAX_SIZE) { + viewFullPost() + } + } + }, []) + const viewFullPost = () => { if (postBodyRef.current && viewFullPostBtnRef.current) { postBodyRef.current.style.maxHeight = 'unset' @@ -471,12 +487,6 @@ const Body = ({ } } - const editor = useEditor({ - content: body, - extensions: [StarterKit, Link], - editable: false - }) - return ( <>
@@ -493,9 +503,12 @@ const Body = ({
- +

Read Full

diff --git a/src/pages/mod/loader.ts b/src/pages/mod/loader.ts index 22476f1..032d697 100644 --- a/src/pages/mod/loader.ts +++ b/src/pages/mod/loader.ts @@ -28,7 +28,7 @@ export const modRouteLoader = const { naddr } = params if (!naddr) { log(true, LogType.Error, 'Required naddr.') - return redirect(appRoutes.blogs) + return redirect(appRoutes.mods) } // Decode from naddr @@ -42,7 +42,7 @@ export const modRouteLoader = pubkey = decoded.data.pubkey } catch (error) { log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error) - throw new Error('Failed to fetch the blog. The address might be wrong') + throw new Error('Failed to fetch the mod. The address might be wrong') } const userState = store.getState().user @@ -80,7 +80,7 @@ export const modRouteLoader = latestFilter['#L'] = ['content-warning'] } - // Parallel fetch blog event, latest events, mute, and nsfw lists + // Parallel fetch mod event, latest events, mute, nsfw, repost lists const settled = await Promise.allSettled([ ndkContext.fetchEvent(modFilter), ndkContext.fetchEvents(latestFilter), @@ -106,7 +106,7 @@ export const modRouteLoader = log( true, LogType.Error, - 'Unable to fetch the blog event.', + 'Unable to fetch the mod event.', fetchEventResult.reason ) } diff --git a/src/pages/submitMod.tsx b/src/pages/submitMod.tsx deleted file mode 100644 index ba79874..0000000 --- a/src/pages/submitMod.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { NDKFilter } from '@nostr-dev-kit/ndk' -import { nip19 } from 'nostr-tools' -import { useState } from 'react' -import { useLocation, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' -import { LoadingSpinner } from '../components/LoadingSpinner' -import { ModForm } from '../components/ModForm' -import { ProfileSection } from '../components/ProfileSection' -import { useAppSelector, useDidMount, useNDKContext } from '../hooks' -import '../styles/innerPage.css' -import '../styles/styles.css' -import '../styles/write.css' -import { ModDetails } from '../types' -import { extractModData, log, LogType } from '../utils' - -export const SubmitModPage = () => { - const location = useLocation() - const { naddr } = useParams() - const { fetchEvent } = useNDKContext() - const [modData, setModData] = useState() - const [isFetching, setIsFetching] = useState(false) - - const userState = useAppSelector((state) => state.user) - - const title = location.pathname.startsWith('/edit-mod') - ? 'Edit Mod' - : 'Submit a mod' - - useDidMount(async () => { - if (naddr) { - const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data - - const filter: NDKFilter = { - '#a': [identifier], - authors: [pubkey], - kinds: [kind] - } - - setIsFetching(true) - - fetchEvent(filter) - .then((event) => { - if (event) { - const extracted = extractModData(event) - setModData(extracted) - } - }) - .catch((err) => { - log( - true, - LogType.Error, - 'An error occurred in fetching mod details from relays', - err - ) - toast.error('An error occurred in fetching mod details from relays') - }) - .finally(() => { - setIsFetching(false) - }) - } - }) - return ( -
-
-
-
-
-
-

{title}

-
-
- {isFetching ? ( - - ) : ( - - )} -
-
- {userState.auth && userState.user?.pubkey && ( - - )} -
-
-
-
- ) -} diff --git a/src/pages/submitMod/action.ts b/src/pages/submitMod/action.ts new file mode 100644 index 0000000..a731d0c --- /dev/null +++ b/src/pages/submitMod/action.ts @@ -0,0 +1,238 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, Event, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs, redirect } from 'react-router-dom' +import { toast } from 'react-toastify' +import { getModPageRoute } from 'routes' +import { store } from 'store' +import { FormErrors, ModFormState } from 'types' +import { + isReachable, + isValidImageUrl, + isValidUrl, + log, + LogType, + now +} from 'utils' +import { v4 as uuidv4 } from 'uuid' +import { T_TAG_VALUE } from '../../constants' + +export const submitModRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const userState = store.getState().user + let hexPubkey: string + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + try { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } catch (error) { + if (error instanceof Error) { + log(true, LogType.Error, 'Failed to get public key.', error) + } + + toast.error('Failed to get public key.') + return null + } + } + + if (!hexPubkey) { + toast.error('Could not get pubkey') + return null + } + + // Get the form data from submit request + try { + const formState = (await request.json()) as ModFormState + + // Check for errors + const formErrors = await validateState(formState) + + // Return earily if there are any errors + if (Object.keys(formErrors).length) return formErrors + + // Check if we are editing or this is a new mob + const { naddr } = params + const isEditing = naddr && request.method === 'PUT' + + const uuid = formState.dTag || uuidv4() + const currentTimeStamp = now() + const aTag = + formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` + + const tags = [ + ['d', uuid], + ['a', aTag], + ['r', formState.rTag], + ['t', T_TAG_VALUE], + [ + 'published_at', + isEditing + ? formState.published_at.toString() + : currentTimeStamp.toString() + ], + ['game', formState.game], + ['title', formState.title], + ['featuredImageUrl', formState.featuredImageUrl], + ['summary', formState.summary], + ['nsfw', formState.nsfw.toString()], + ['repost', formState.repost.toString()], + ['screenshotsUrls', ...formState.screenshotsUrls], + ['tags', ...formState.tags.split(',')], + [ + 'downloadUrls', + ...formState.downloadUrls.map((downloadUrl) => + JSON.stringify(downloadUrl) + ) + ] + ] + if (formState.repost && formState.originalAuthor) { + tags.push(['originalAuthor', formState.originalAuthor]) + } + + // Prepend com.degmods to avoid leaking categories to 3rd party client's search + // Add hierarchical namespaces labels + if (formState.LTags.length > 0) { + for (let i = 0; i < formState.LTags.length; i++) { + tags.push(['L', `com.degmods:${formState.LTags[i]}`]) + } + } + + // Add category labels + if (formState.lTags.length > 0) { + for (let i = 0; i < formState.lTags.length; i++) { + tags.push(['l', `com.degmods:${formState.lTags[i]}`]) + } + } + + const unsignedEvent: UnsignedEvent = { + kind: kinds.ClassifiedListing, + created_at: currentTimeStamp, + pubkey: hexPubkey, + content: formState.body, + tags + } + + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) { + toast.error('Failed to sign the event!') + return null + } + + const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent) + const publishedOnRelays = await ndkContext.publish(ndkEvent) + + // Handle cases where publishing failed or succeeded + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + } else { + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) + + const naddr = nip19.naddrEncode({ + identifier: aTag, + pubkey: signedEvent.pubkey, + kind: signedEvent.kind, + relays: publishedOnRelays + }) + + return redirect(getModPageRoute(naddr)) + } + } catch (error) { + log(true, LogType.Error, 'Failed to sign the event!', error) + toast.error('Failed to sign the event!') + return null + } + + return null + } + +const validateState = async ( + formState: Partial +): Promise => { + const errors: FormErrors = {} + + if (!formState.game || formState.game === '') { + errors.game = 'Game field can not be empty' + } + + if (!formState.title || formState.title === '') { + errors.title = 'Title field can not be empty' + } + + if (!formState.body || formState.body === '') { + errors.body = 'Body field can not be empty' + } + + if (!formState.featuredImageUrl || formState.featuredImageUrl === '') { + errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty' + } else if ( + !isValidImageUrl(formState.featuredImageUrl) || + !(await isReachable(formState.featuredImageUrl)) + ) { + errors.featuredImageUrl = + 'FeaturedImageUrl must be a valid and reachable image URL' + } + + if (!formState.summary || formState.summary === '') { + errors.summary = 'Summary field can not be empty' + } + + if (!formState.screenshotsUrls || formState.screenshotsUrls.length === 0) { + errors.screenshotsUrls = ['Required at least one screenshot url'] + } else { + for (let i = 0; i < formState.screenshotsUrls.length; i++) { + const url = formState.screenshotsUrls[i] + if ( + !isValidUrl(url) || + !isValidImageUrl(url) || + !(await isReachable(url)) + ) { + if (!errors.screenshotsUrls) + errors.screenshotsUrls = Array(formState.screenshotsUrls.length) + + errors.screenshotsUrls![i] = + 'All screenshot URLs must be valid and reachable image URLs' + } + } + } + + if ( + formState.repost && + (!formState.originalAuthor || formState.originalAuthor === '') + ) { + errors.originalAuthor = 'Original author field can not be empty' + } + + if (!formState.tags || formState.tags === '') { + errors.tags = 'Tags field can not be empty' + } + + if (!formState.downloadUrls || formState.downloadUrls.length === 0) { + errors.downloadUrls = ['Required at least one download url'] + } else { + for (let i = 0; i < formState.downloadUrls.length; i++) { + const downloadUrl = formState.downloadUrls[i] + if (!isValidUrl(downloadUrl.url)) { + if (!errors.downloadUrls) + errors.downloadUrls = Array(formState.downloadUrls.length) + + errors.downloadUrls![i] = 'Download url must be valid and reachable' + } + } + } + + return errors +} diff --git a/src/pages/submitMod/index.tsx b/src/pages/submitMod/index.tsx new file mode 100644 index 0000000..cfd1b66 --- /dev/null +++ b/src/pages/submitMod/index.tsx @@ -0,0 +1,39 @@ +import { LoadingSpinner } from 'components/LoadingSpinner' +import { ModForm } from 'components/ModForm' +import { ProfileSection } from 'components/ProfileSection' +import { useAppSelector } from 'hooks' +import { useLoaderData, useNavigation } from 'react-router-dom' +import { ModPageLoaderResult } from 'types' + +export const SubmitModPage = () => { + const data = useLoaderData() as ModPageLoaderResult + const mod = data?.mod + const navigation = useNavigation() + const userState = useAppSelector((state) => state.user) + const title = mod ? 'Edit Mod' : 'Submit a mod' + return ( +
+
+
+
+
+
+

{title}

+
+ {navigation.state === 'loading' && ( + + )} + {navigation.state === 'submitting' && ( + + )} + +
+ {userState.auth && userState.user?.pubkey && ( + + )} +
+
+
+
+ ) +} diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index 1e9088d..2bd4bb0 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -18,8 +18,7 @@ import '../../styles/styles.css' import '../../styles/write.css' import { LoadingSpinner } from 'components/LoadingSpinner' import { AlertPopup } from 'components/AlertPopup' - -import { Editor } from 'components/editor' +import { Editor, EditorRef } from 'components/Markdown/Editor' export const WritePage = () => { const userState = useAppSelector((state) => state.user) @@ -31,6 +30,7 @@ export const WritePage = () => { const title = data?.blog ? 'Edit blog post' : 'Submit a blog post' const [content, setContent] = useState(blog?.content || '') const formRef = useRef(null) + const editorRef = useRef(null) const [showConfirmPopup, setShowConfirmPopup] = useState(false) const handleReset = () => { setShowConfirmPopup(true) @@ -41,6 +41,11 @@ export const WritePage = () => { // Cancel if not confirmed if (!confirm) return + // Reset editor + if (blog?.content) { + editorRef.current?.setMarkdown(blog?.content) + } + formRef.current?.reset() } @@ -74,6 +79,7 @@ export const WritePage = () => {
{ setContent(md) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 54bdc0a..bee8638 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -16,6 +16,7 @@ import { profileRouteLoader } from 'pages/profile/loader' import { SettingsPage } from '../pages/settings' import { GamePage } from '../pages/game' import { NotFoundPage } from '../pages/404' +import { submitModRouteAction } from 'pages/submitMod/action' import { FeedLayout } from '../layout/feed' import { FeedPage } from '../pages/feed' import { NotificationsPage } from '../pages/notifications' @@ -29,7 +30,6 @@ import { blogRouteAction } from '../pages/blog/action' import { reportRouteAction } from '../actions/report' export const appRoutes = { - index: '/', home: '/', games: '/games', game: '/game/:name', @@ -75,7 +75,7 @@ export const routerWithNdkContext = (context: NDKContextType) => element: , children: [ { - path: appRoutes.index, + path: appRoutes.home, element: }, { @@ -131,11 +131,16 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.submitMod, - element: + action: submitModRouteAction(context), + element: , + errorElement: }, { path: appRoutes.editMod, - element: + loader: modRouteLoader(context), + action: submitModRouteAction(context), + element: , + errorElement: }, { path: appRoutes.write, diff --git a/src/styles/mdxEditor.scss b/src/styles/mdxEditor.scss index 9e9384b..4cc4a2a 100644 --- a/src/styles/mdxEditor.scss +++ b/src/styles/mdxEditor.scss @@ -1,6 +1,6 @@ -.editor { +.editor, +.viewer { padding: 0; - --basePageBg: var(--slate-3); > { p { @@ -96,7 +96,11 @@ border-radius: 10px; } } - +.editor { + --basePageBg: var(--slate-3); + padding-top: 10px; + min-height: 75px; +} .mdxeditor, .mdxeditor-popup-container { --basePageBg: var(--slate-3); diff --git a/src/types/mod.ts b/src/types/mod.ts index c520ac3..799ef64 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -36,6 +36,7 @@ export interface ModFormState { /** Category labels for category search */ lTags: string[] downloadUrls: DownloadUrl[] + published_at: number } export interface DownloadUrl { @@ -49,7 +50,6 @@ export interface DownloadUrl { export interface ModDetails extends Omit { id: string - published_at: number edited_at: number author: string tags: string[] @@ -67,3 +67,17 @@ export interface ModPageLoaderResult { isBlocked: boolean isRepost: boolean } + +export interface FormErrors { + game?: string + title?: string + body?: string + featuredImageUrl?: string + summary?: string + nsfw?: string + screenshotsUrls?: string[] + tags?: string + downloadUrls?: string[] + author?: string + originalAuthor?: string +} diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 07f34a0..71ceb39 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -119,6 +119,7 @@ export const initializeFormState = ( ): ModFormState => ({ dTag: existingModData?.dTag || '', aTag: existingModData?.aTag || '', + published_at: existingModData?.published_at || 0, rTag: existingModData?.rTag || window.location.host, game: existingModData?.game || '', title: existingModData?.title || '',