refactor: add viewer, swap editor, and refactor submitMod page
This commit is contained in:
parent
70c15dceb0
commit
1b1aa4289a
@ -330,7 +330,7 @@ const MenuBarButton = ({
|
||||
)
|
||||
|
||||
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
131
src/components/Markdown/Editor.tsx
Normal file
131
src/components/Markdown/Editor.tsx
Normal file
@ -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<EditorRef, EditorProps>(({ markdown, onChange, ...rest }, ref) => {
|
||||
const editorRef = useRef<MDXEditorMethods>(null)
|
||||
const setMarkdown = useCallback((md: string) => {
|
||||
editorRef.current?.setMarkdown(md)
|
||||
}, [])
|
||||
useImperativeHandle(ref, () => ({ setMarkdown }))
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<>
|
||||
<UndoRedo />
|
||||
<Separator />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<CodeToggle />
|
||||
<Separator />
|
||||
<StrikeThroughSupSubToggles />
|
||||
<Separator />
|
||||
<ListsToggle />
|
||||
<Separator />
|
||||
<BlockTypeSelect />
|
||||
<Separator />
|
||||
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
<YouTubeButton />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InsertTable />
|
||||
<InsertThematicBreak />
|
||||
|
||||
<Separator />
|
||||
<InsertCodeBlock />
|
||||
</>
|
||||
)
|
||||
}),
|
||||
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 (
|
||||
<MDXEditor
|
||||
ref={editorRef}
|
||||
contentEditableClassName='editor'
|
||||
className='dark-theme dark-editor'
|
||||
markdown={markdown}
|
||||
plugins={plugins}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
() => true
|
||||
)
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
13
src/components/Markdown/Viewer.tsx
Normal file
13
src/components/Markdown/Viewer.tsx
Normal file
@ -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 (
|
||||
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||
)
|
||||
}
|
@ -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<GameOption[]>([])
|
||||
const [formState, setFormState] = useState<ModFormState>(
|
||||
initializeFormState()
|
||||
initializeFormState(mod)
|
||||
)
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
||||
|
||||
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<EditorRef>(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<boolean> => {
|
||||
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 && <LoadingSpinner desc='Publishing mod to relays' />}
|
||||
<form
|
||||
className='IBMSMSMBS_Write'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handlePublish()
|
||||
}}
|
||||
>
|
||||
<GameDropdown
|
||||
options={gameOptions}
|
||||
selected={formState.game}
|
||||
error={formErrors.game}
|
||||
selected={formState?.game}
|
||||
error={formErrors?.game}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label='Body'
|
||||
type='richtext'
|
||||
placeholder="Here's what this mod is all about"
|
||||
name='body'
|
||||
value={formState.body}
|
||||
error={formErrors.body}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Body</label>
|
||||
<div className='inputMain'>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
markdown={markdown}
|
||||
placeholder="Here's what this mod is all about"
|
||||
onChange={(md) => {
|
||||
handleInputChange('body', md)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{typeof formErrors?.body !== 'undefined' && (
|
||||
<InputError message={formErrors?.body} />
|
||||
)}
|
||||
<input
|
||||
name='body'
|
||||
hidden
|
||||
value={encodeURIComponent(formState?.body)}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label='Featured Image URL'
|
||||
@ -433,7 +234,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
placeholder='Image URL'
|
||||
name='featuredImageUrl'
|
||||
value={formState.featuredImageUrl}
|
||||
error={formErrors.featuredImageUrl}
|
||||
error={formErrors?.featuredImageUrl}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputField
|
||||
@ -442,7 +243,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
placeholder='This is a quick description of my mod'
|
||||
name='summary'
|
||||
value={formState.summary}
|
||||
error={formErrors.summary}
|
||||
error={formErrors?.summary}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CheckboxField
|
||||
@ -472,7 +273,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
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] && (
|
||||
<InputError message={formErrors.screenshotsUrls[index]} />
|
||||
{formErrors?.screenshotsUrls &&
|
||||
formErrors?.screenshotsUrls[index] && (
|
||||
<InputError message={formErrors?.screenshotsUrls[index]} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{formState.screenshotsUrls.length === 0 &&
|
||||
formErrors.screenshotsUrls &&
|
||||
formErrors.screenshotsUrls[0] && (
|
||||
<InputError message={formErrors.screenshotsUrls[0]} />
|
||||
formErrors?.screenshotsUrls &&
|
||||
formErrors?.screenshotsUrls[0] && (
|
||||
<InputError message={formErrors?.screenshotsUrls[0]} />
|
||||
)}
|
||||
</div>
|
||||
<InputField
|
||||
@ -526,7 +327,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
placeholder='Tags'
|
||||
name='tags'
|
||||
value={formState.tags}
|
||||
error={formErrors.tags}
|
||||
error={formErrors?.tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CategoryAutocomplete
|
||||
@ -573,15 +374,15 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
onUrlChange={handleDownloadUrlChange}
|
||||
onRemove={removeDownloadUrl}
|
||||
/>
|
||||
{formErrors.downloadUrls && formErrors.downloadUrls[index] && (
|
||||
<InputError message={formErrors.downloadUrls[index]} />
|
||||
{formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
|
||||
<InputError message={formErrors?.downloadUrls[index]} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{formState.downloadUrls.length === 0 &&
|
||||
formErrors.downloadUrls &&
|
||||
formErrors.downloadUrls[0] && (
|
||||
<InputError message={formErrors.downloadUrls[0]} />
|
||||
formErrors?.downloadUrls &&
|
||||
formErrors?.downloadUrls[0] && (
|
||||
<InputError message={formErrors?.downloadUrls[0]} />
|
||||
)}
|
||||
</div>
|
||||
<div className='IBMSMSMBS_WriteAction'>
|
||||
@ -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'}
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing}
|
||||
type='submit'
|
||||
disabled={
|
||||
navigation.state === 'loading' || navigation.state === 'submitting'
|
||||
}
|
||||
>
|
||||
Publish
|
||||
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
{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?`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
type DownloadUrlFieldsProps = {
|
||||
|
@ -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: () => (
|
||||
<>
|
||||
<UndoRedo />
|
||||
<Separator />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<CodeToggle />
|
||||
<Separator />
|
||||
<StrikeThroughSupSubToggles />
|
||||
<Separator />
|
||||
<ListsToggle />
|
||||
<Separator />
|
||||
<BlockTypeSelect />
|
||||
<Separator />
|
||||
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
<YouTubeButton />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InsertTable />
|
||||
<InsertThematicBreak />
|
||||
|
||||
<Separator />
|
||||
<InsertCodeBlock />
|
||||
</>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MDXEditor
|
||||
contentEditableClassName='editor'
|
||||
className='dark-theme dark-editor'
|
||||
readOnly={readOnly}
|
||||
markdown={markdown}
|
||||
plugins={plugins}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
@ -91,10 +91,7 @@ export const Header = () => {
|
||||
<div className={mainStyles.ContainerMain}>
|
||||
<div className={navStyles.NavMainTopInside}>
|
||||
<div className={navStyles.NMTI_Sec}>
|
||||
<Link
|
||||
to={appRoutes.index}
|
||||
className={navStyles.NMTI_Sec_HomeLink}
|
||||
>
|
||||
<Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
|
||||
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
|
||||
<img
|
||||
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
|
||||
|
@ -17,7 +17,7 @@ import { copyTextToClipboard } from 'utils'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
||||
import { ReportPopup } from 'components/ReportPopup'
|
||||
import { Editor } from 'components/editor'
|
||||
import { Viewer } from 'components/Markdown/Viewer'
|
||||
|
||||
const BLOG_REPORT_REASONS = [
|
||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||
@ -242,11 +242,9 @@ export const BlogPage = () => {
|
||||
</h1>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSPostBody'>
|
||||
<Editor
|
||||
<Viewer
|
||||
key={blog.id}
|
||||
markdown={blog?.content || ''}
|
||||
readOnly={true}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSTags'>
|
||||
|
@ -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<HTMLDivElement>(null)
|
||||
const viewFullPostBtnRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div className='IBMSMSMBSSPost'>
|
||||
@ -493,9 +503,12 @@ const Body = ({
|
||||
<div
|
||||
ref={postBodyRef}
|
||||
className='IBMSMSMBSSPostBody'
|
||||
style={{ maxHeight: '250px', padding: '10px 18px' }}
|
||||
style={{
|
||||
maxHeight: `${COLLAPSED_MAX_SIZE}px`,
|
||||
padding: '10px 18px'
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
<Viewer markdown={markdown} />
|
||||
<div ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
|
||||
<div className='IBMSMSMBSSPostBodyHideText'>
|
||||
<p onClick={viewFullPost}>Read Full</p>
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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<ModDetails>()
|
||||
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 (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div className='IBMSMSplitMain'>
|
||||
<div className='IBMSMSplitMainBigSide'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
|
||||
</div>
|
||||
<div className='IBMSMSMBS_Write'>
|
||||
{isFetching ? (
|
||||
<LoadingSpinner desc='Fetching mod details from relays' />
|
||||
) : (
|
||||
<ModForm existingModData={modData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
238
src/pages/submitMod/action.ts
Normal file
238
src/pages/submitMod/action.ts
Normal file
@ -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<ModFormState>
|
||||
): Promise<FormErrors> => {
|
||||
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
|
||||
}
|
39
src/pages/submitMod/index.tsx
Normal file
39
src/pages/submitMod/index.tsx
Normal file
@ -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 (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div className='IBMSMSplitMain'>
|
||||
<div className='IBMSMSplitMainBigSide'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
|
||||
</div>
|
||||
{navigation.state === 'loading' && (
|
||||
<LoadingSpinner desc='Fetching mod details from relays' />
|
||||
)}
|
||||
{navigation.state === 'submitting' && (
|
||||
<LoadingSpinner desc='Publishing mod to relays' />
|
||||
)}
|
||||
<ModForm />
|
||||
</div>
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -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<HTMLFormElement>(null)
|
||||
const editorRef = useRef<EditorRef>(null)
|
||||
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(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 = () => {
|
||||
<label className='form-label labelMain'>Content</label>
|
||||
<div className='inputMain'>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
markdown={content}
|
||||
onChange={(md) => {
|
||||
setContent(md)
|
||||
|
@ -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: <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: appRoutes.index,
|
||||
path: appRoutes.home,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
@ -131,11 +131,16 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
||||
},
|
||||
{
|
||||
path: appRoutes.submitMod,
|
||||
element: <SubmitModPage key='submit' />
|
||||
action: submitModRouteAction(context),
|
||||
element: <SubmitModPage key='submit' />,
|
||||
errorElement: <NotFoundPage title={'Something went wrong.'} />
|
||||
},
|
||||
{
|
||||
path: appRoutes.editMod,
|
||||
element: <SubmitModPage key='edit' />
|
||||
loader: modRouteLoader(context),
|
||||
action: submitModRouteAction(context),
|
||||
element: <SubmitModPage key='edit' />,
|
||||
errorElement: <NotFoundPage title={'Something went wrong.'} />
|
||||
},
|
||||
{
|
||||
path: appRoutes.write,
|
||||
|
@ -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);
|
||||
|
@ -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<ModFormState, 'tags'> {
|
||||
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
|
||||
}
|
||||
|
@ -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 || '',
|
||||
|
Loading…
x
Reference in New Issue
Block a user