categories,18popup,clear.TextEditorSwap,GameCardHover #177

Merged
freakoverse merged 64 commits from staging into master 2024-12-24 19:44:30 +00:00
20 changed files with 596 additions and 525 deletions
Showing only changes of commit 1b1aa4289a - Show all commits

View File

@ -330,7 +330,7 @@ const MenuBarButton = ({
) )
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> { interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string label: string | React.ReactElement
description?: string description?: string
error?: string error?: string
} }

View 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
)

View File

@ -35,7 +35,8 @@ export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
if (event.key === 'Backspace' || event.key === 'Delete') { if (event.key === 'Backspace' || event.key === 'Delete') {
if (codeRef.current?.textContent === '') { if (codeRef.current?.textContent === '') {
parentEditor.update(() => { parentEditor.update(() => {
lexicalNode.remove(false) lexicalNode.selectNext()
lexicalNode.remove()
}) })
} }
} }

View 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>
)
}

View File

@ -1,5 +1,4 @@
import _ from 'lodash' import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, { import React, {
Fragment, Fragment,
useCallback, useCallback,
@ -8,80 +7,51 @@ import React, {
useRef, useRef,
useState useState
} from 'react' } 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 { import {
initializeFormState, useActionData,
isReachable, useLoaderData,
isValidImageUrl, useNavigation,
isValidUrl, useSubmit
log, } from 'react-router-dom'
LogType, import { FixedSizeList } from 'react-window'
now import { useGames } from '../hooks'
} from '../utils' import '../styles/styles.css'
import {
DownloadUrl,
FormErrors,
ModFormState,
ModPageLoaderResult
} from '../types'
import { initializeFormState } from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs' import { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { OriginalAuthor } from './OriginalAuthor' import { OriginalAuthor } from './OriginalAuthor'
import { CategoryAutocomplete } from './CategoryAutocomplete' import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup' import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor'
interface FormErrors { import TurndownService from 'turndown'
game?: string import DOMPurify from 'dompurify'
title?: string
body?: string
featuredImageUrl?: string
summary?: string
nsfw?: string
screenshotsUrls?: string[]
tags?: string
downloadUrls?: string[]
author?: string
originalAuthor?: string
}
interface GameOption { interface GameOption {
value: string value: string
label: string label: string
} }
type ModFormProps = { export const ModForm = () => {
existingModData?: ModDetails const data = useLoaderData() as ModPageLoaderResult
} const mod = data?.mod
const formErrors = useActionData() as FormErrors
export const ModForm = ({ existingModData }: ModFormProps) => { const navigation = useNavigation()
const location = useLocation() const submit = useSubmit()
const navigate = useNavigate()
const { ndk, publish } = useNDKContext()
const games = useGames() const games = useGames()
const userState = useAppSelector((state) => state.user)
const [isPublishing, setIsPublishing] = useState(false)
const [gameOptions, setGameOptions] = useState<GameOption[]>([]) const [gameOptions, setGameOptions] = useState<GameOption[]>([])
const [formState, setFormState] = useState<ModFormState>( const [formState, setFormState] = useState<ModFormState>(
initializeFormState() initializeFormState(mod)
) )
const [formErrors, setFormErrors] = useState<FormErrors>({}) const editorRef = useRef<EditorRef>(null)
const sanitized = DOMPurify.sanitize(formState.body)
useEffect(() => { const turndown = new TurndownService()
if (location.pathname === appRoutes.submitMod) { turndown.keep(['sup', 'sub'])
// Only trigger when the pathname changes to submit-mod const markdown = turndown.turndown(sanitized)
setFormState(initializeFormState())
}
}, [location.pathname])
useEffect(() => {
if (existingModData) {
setFormState(initializeFormState(existingModData))
}
}, [existingModData])
useEffect(() => { useEffect(() => {
const options = games.map((game) => ({ const options = games.map((game) => ({
@ -188,221 +158,39 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
if (!confirm) return if (!confirm) return
// Editing // Editing
if (existingModData) { if (mod) {
const initial = initializeFormState(mod)
// Reset editor
editorRef.current?.setMarkdown(initial.body)
// Reset fields to the original existing data // Reset fields to the original existing data
setFormState(initializeFormState(existingModData)) setFormState(initial)
return return
} }
// New - set form state to the initial (clear form state) // New - set form state to the initial (clear form state)
setFormState(initializeFormState()) setFormState(initializeFormState())
} }
const handlePublish = () => {
const handlePublish = async () => { submit(JSON.stringify(formState), {
setIsPublishing(true) method: mod ? 'put' : 'post',
encType: 'application/json'
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
} }
return ( return (
<> <form
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />} className='IBMSMSMBS_Write'
onSubmit={(e) => {
e.preventDefault()
handlePublish()
}}
>
<GameDropdown <GameDropdown
options={gameOptions} options={gameOptions}
selected={formState.game} selected={formState?.game}
error={formErrors.game} error={formErrors?.game}
onChange={handleInputChange} onChange={handleInputChange}
/> />
@ -411,19 +199,32 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Return the banana mod' placeholder='Return the banana mod'
name='title' name='title'
value={formState.title} value={formState.title}
error={formErrors.title} error={formErrors?.title}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<InputField <div className='inputLabelWrapperMain'>
label='Body' <label className='form-label labelMain'>Body</label>
type='richtext' <div className='inputMain'>
<Editor
ref={editorRef}
markdown={markdown}
placeholder="Here's what this mod is all about" placeholder="Here's what this mod is all about"
name='body' onChange={(md) => {
value={formState.body} handleInputChange('body', md)
error={formErrors.body} }}
onChange={handleInputChange}
/> />
</div>
{typeof formErrors?.body !== 'undefined' && (
<InputError message={formErrors?.body} />
)}
<input
name='body'
hidden
value={encodeURIComponent(formState?.body)}
readOnly
/>
</div>
<InputField <InputField
label='Featured Image URL' label='Featured Image URL'
@ -433,7 +234,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Image URL' placeholder='Image URL'
name='featuredImageUrl' name='featuredImageUrl'
value={formState.featuredImageUrl} value={formState.featuredImageUrl}
error={formErrors.featuredImageUrl} error={formErrors?.featuredImageUrl}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<InputField <InputField
@ -442,7 +243,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='This is a quick description of my mod' placeholder='This is a quick description of my mod'
name='summary' name='summary'
value={formState.summary} value={formState.summary}
error={formErrors.summary} error={formErrors?.summary}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<CheckboxField <CheckboxField
@ -472,7 +273,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder="Original author's name, npub or nprofile" placeholder="Original author's name, npub or nprofile"
name='originalAuthor' name='originalAuthor'
value={formState.originalAuthor || ''} value={formState.originalAuthor || ''}
error={formErrors.originalAuthor || ''} error={formErrors?.originalAuthor}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</> </>
@ -508,16 +309,16 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
onUrlChange={handleScreenshotUrlChange} onUrlChange={handleScreenshotUrlChange}
onRemove={removeScreenshotUrl} onRemove={removeScreenshotUrl}
/> />
{formErrors.screenshotsUrls && {formErrors?.screenshotsUrls &&
formErrors.screenshotsUrls[index] && ( formErrors?.screenshotsUrls[index] && (
<InputError message={formErrors.screenshotsUrls[index]} /> <InputError message={formErrors?.screenshotsUrls[index]} />
)} )}
</Fragment> </Fragment>
))} ))}
{formState.screenshotsUrls.length === 0 && {formState.screenshotsUrls.length === 0 &&
formErrors.screenshotsUrls && formErrors?.screenshotsUrls &&
formErrors.screenshotsUrls[0] && ( formErrors?.screenshotsUrls[0] && (
<InputError message={formErrors.screenshotsUrls[0]} /> <InputError message={formErrors?.screenshotsUrls[0]} />
)} )}
</div> </div>
<InputField <InputField
@ -526,7 +327,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Tags' placeholder='Tags'
name='tags' name='tags'
value={formState.tags} value={formState.tags}
error={formErrors.tags} error={formErrors?.tags}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<CategoryAutocomplete <CategoryAutocomplete
@ -573,15 +374,15 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
onUrlChange={handleDownloadUrlChange} onUrlChange={handleDownloadUrlChange}
onRemove={removeDownloadUrl} onRemove={removeDownloadUrl}
/> />
{formErrors.downloadUrls && formErrors.downloadUrls[index] && ( {formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
<InputError message={formErrors.downloadUrls[index]} /> <InputError message={formErrors?.downloadUrls[index]} />
)} )}
</Fragment> </Fragment>
))} ))}
{formState.downloadUrls.length === 0 && {formState.downloadUrls.length === 0 &&
formErrors.downloadUrls && formErrors?.downloadUrls &&
formErrors.downloadUrls[0] && ( formErrors?.downloadUrls[0] && (
<InputError message={formErrors.downloadUrls[0]} /> <InputError message={formErrors?.downloadUrls[0]} />
)} )}
</div> </div>
<div className='IBMSMSMBS_WriteAction'> <div className='IBMSMSMBS_WriteAction'>
@ -589,17 +390,20 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
className='btn btnMain' className='btn btnMain'
type='button' type='button'
onClick={handleReset} onClick={handleReset}
disabled={isPublishing} disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
> >
{existingModData ? 'Reset' : 'Clear fields'} {mod ? 'Reset' : 'Clear fields'}
</button> </button>
<button <button
className='btn btnMain' className='btn btnMain'
type='button' type='submit'
onClick={handlePublish} disabled={
disabled={isPublishing} navigation.state === 'loading' || navigation.state === 'submitting'
}
> >
Publish {navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
</button> </button>
</div> </div>
{showConfirmPopup && ( {showConfirmPopup && (
@ -608,13 +412,13 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
handleClose={() => setShowConfirmPopup(false)} handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'} header={'Are you sure?'}
label={ label={
existingModData mod
? `Are you sure you want to clear all changes?` ? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?` : `Are you sure you want to clear all field data?`
} }
/> />
)} )}
</> </form>
) )
} }
type DownloadUrlFieldsProps = { type DownloadUrlFieldsProps = {

View File

@ -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}
/>
)
}

View File

@ -91,10 +91,7 @@ export const Header = () => {
<div className={mainStyles.ContainerMain}> <div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainTopInside}> <div className={navStyles.NavMainTopInside}>
<div className={navStyles.NMTI_Sec}> <div className={navStyles.NMTI_Sec}>
<Link <Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
to={appRoutes.index}
className={navStyles.NMTI_Sec_HomeLink}
>
<div className={navStyles.NMTI_Sec_HomeLink_Logo}> <div className={navStyles.NMTI_Sec_HomeLink_Logo}>
<img <img
className={navStyles.NMTI_Sec_HomeLink_LogoImg} className={navStyles.NMTI_Sec_HomeLink_LogoImg}

View File

@ -17,7 +17,7 @@ import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable } from 'hooks' import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { ReportPopup } from 'components/ReportPopup' import { ReportPopup } from 'components/ReportPopup'
import { Editor } from 'components/editor' import { Viewer } from 'components/Markdown/Viewer'
const BLOG_REPORT_REASONS = [ const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' }, { label: 'Actually CP', key: 'actuallyCP' },
@ -242,11 +242,9 @@ export const BlogPage = () => {
</h1> </h1>
</div> </div>
<div className='IBMSMSMBSSPostBody'> <div className='IBMSMSMBSSPostBody'>
<Editor <Viewer
key={blog.id} key={blog.id}
markdown={blog?.content || ''} markdown={blog?.content || ''}
readOnly={true}
spellCheck={false}
/> />
</div> </div>
<div className='IBMSMSMBSSTags'> <div className='IBMSMSMBSSTags'>

View File

@ -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 FsLightbox from 'fslightbox-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Link as ReactRouterLink, Link as ReactRouterLink,
useLoaderData, useLoaderData,
@ -40,6 +37,9 @@ import { ReportPopup } from 'components/ReportPopup'
import { Spinner } from 'components/Spinner' import { Spinner } from 'components/Spinner'
import { RouterLoadingSpinner } from 'components/LoadingSpinner' import { RouterLoadingSpinner } from 'components/LoadingSpinner'
import { OriginalAuthor } from 'components/OriginalAuthor' import { OriginalAuthor } from 'components/OriginalAuthor'
import DOMPurify from 'dompurify'
import TurndownService from 'turndown'
import { Viewer } from 'components/Markdown/Viewer'
const MOD_REPORT_REASONS = [ const MOD_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' }, { label: 'Actually CP', key: 'actuallyCP' },
@ -448,9 +448,17 @@ const Body = ({
repost, repost,
originalAuthor originalAuthor
}: BodyProps) => { }: BodyProps) => {
const COLLAPSED_MAX_SIZE = 250
const postBodyRef = useRef<HTMLDivElement>(null) const postBodyRef = useRef<HTMLDivElement>(null)
const viewFullPostBtnRef = 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({ const [lightBoxController, setLightBoxController] = useState({
toggler: false, toggler: false,
slide: 1 slide: 1
@ -463,6 +471,14 @@ const Body = ({
})) }))
} }
useEffect(() => {
if (postBodyRef.current) {
if (postBodyRef.current.scrollHeight <= COLLAPSED_MAX_SIZE) {
viewFullPost()
}
}
}, [])
const viewFullPost = () => { const viewFullPost = () => {
if (postBodyRef.current && viewFullPostBtnRef.current) { if (postBodyRef.current && viewFullPostBtnRef.current) {
postBodyRef.current.style.maxHeight = 'unset' postBodyRef.current.style.maxHeight = 'unset'
@ -471,12 +487,6 @@ const Body = ({
} }
} }
const editor = useEditor({
content: body,
extensions: [StarterKit, Link],
editable: false
})
return ( return (
<> <>
<div className='IBMSMSMBSSPost'> <div className='IBMSMSMBSSPost'>
@ -493,9 +503,12 @@ const Body = ({
<div <div
ref={postBodyRef} ref={postBodyRef}
className='IBMSMSMBSSPostBody' 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 ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
<div className='IBMSMSMBSSPostBodyHideText'> <div className='IBMSMSMBSSPostBodyHideText'>
<p onClick={viewFullPost}>Read Full</p> <p onClick={viewFullPost}>Read Full</p>

View File

@ -28,7 +28,7 @@ export const modRouteLoader =
const { naddr } = params const { naddr } = params
if (!naddr) { if (!naddr) {
log(true, LogType.Error, 'Required naddr.') log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs) return redirect(appRoutes.mods)
} }
// Decode from naddr // Decode from naddr
@ -42,7 +42,7 @@ export const modRouteLoader =
pubkey = decoded.data.pubkey pubkey = decoded.data.pubkey
} catch (error) { } catch (error) {
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, 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 const userState = store.getState().user
@ -80,7 +80,7 @@ export const modRouteLoader =
latestFilter['#L'] = ['content-warning'] 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([ const settled = await Promise.allSettled([
ndkContext.fetchEvent(modFilter), ndkContext.fetchEvent(modFilter),
ndkContext.fetchEvents(latestFilter), ndkContext.fetchEvents(latestFilter),
@ -106,7 +106,7 @@ export const modRouteLoader =
log( log(
true, true,
LogType.Error, LogType.Error,
'Unable to fetch the blog event.', 'Unable to fetch the mod event.',
fetchEventResult.reason fetchEventResult.reason
) )
} }

View File

@ -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>
)
}

View 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
}

View 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>
)
}

View File

@ -18,8 +18,7 @@ import '../../styles/styles.css'
import '../../styles/write.css' import '../../styles/write.css'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { AlertPopup } from 'components/AlertPopup' import { AlertPopup } from 'components/AlertPopup'
import { Editor, EditorRef } from 'components/Markdown/Editor'
import { Editor } from 'components/editor'
export const WritePage = () => { export const WritePage = () => {
const userState = useAppSelector((state) => state.user) 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 title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const [content, setContent] = useState(blog?.content || '') const [content, setContent] = useState(blog?.content || '')
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const editorRef = useRef<EditorRef>(null)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false) const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => { const handleReset = () => {
setShowConfirmPopup(true) setShowConfirmPopup(true)
@ -41,6 +41,11 @@ export const WritePage = () => {
// Cancel if not confirmed // Cancel if not confirmed
if (!confirm) return if (!confirm) return
// Reset editor
if (blog?.content) {
editorRef.current?.setMarkdown(blog?.content)
}
formRef.current?.reset() formRef.current?.reset()
} }
@ -74,6 +79,7 @@ export const WritePage = () => {
<label className='form-label labelMain'>Content</label> <label className='form-label labelMain'>Content</label>
<div className='inputMain'> <div className='inputMain'>
<Editor <Editor
ref={editorRef}
markdown={content} markdown={content}
onChange={(md) => { onChange={(md) => {
setContent(md) setContent(md)

View File

@ -16,6 +16,7 @@ import { profileRouteLoader } from 'pages/profile/loader'
import { SettingsPage } from '../pages/settings' import { SettingsPage } from '../pages/settings'
import { GamePage } from '../pages/game' import { GamePage } from '../pages/game'
import { NotFoundPage } from '../pages/404' import { NotFoundPage } from '../pages/404'
import { submitModRouteAction } from 'pages/submitMod/action'
import { FeedLayout } from '../layout/feed' import { FeedLayout } from '../layout/feed'
import { FeedPage } from '../pages/feed' import { FeedPage } from '../pages/feed'
import { NotificationsPage } from '../pages/notifications' import { NotificationsPage } from '../pages/notifications'
@ -29,7 +30,6 @@ import { blogRouteAction } from '../pages/blog/action'
import { reportRouteAction } from '../actions/report' import { reportRouteAction } from '../actions/report'
export const appRoutes = { export const appRoutes = {
index: '/',
home: '/', home: '/',
games: '/games', games: '/games',
game: '/game/:name', game: '/game/:name',
@ -75,7 +75,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
element: <Layout />, element: <Layout />,
children: [ children: [
{ {
path: appRoutes.index, path: appRoutes.home,
element: <HomePage /> element: <HomePage />
}, },
{ {
@ -131,11 +131,16 @@ export const routerWithNdkContext = (context: NDKContextType) =>
}, },
{ {
path: appRoutes.submitMod, path: appRoutes.submitMod,
element: <SubmitModPage key='submit' /> action: submitModRouteAction(context),
element: <SubmitModPage key='submit' />,
errorElement: <NotFoundPage title={'Something went wrong.'} />
}, },
{ {
path: appRoutes.editMod, 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, path: appRoutes.write,

View File

@ -1,6 +1,6 @@
.editor { .editor,
.viewer {
padding: 0; padding: 0;
--basePageBg: var(--slate-3);
> { > {
p { p {
@ -96,7 +96,11 @@
border-radius: 10px; border-radius: 10px;
} }
} }
.editor {
--basePageBg: var(--slate-3);
padding-top: 10px;
min-height: 75px;
}
.mdxeditor, .mdxeditor,
.mdxeditor-popup-container { .mdxeditor-popup-container {
--basePageBg: var(--slate-3); --basePageBg: var(--slate-3);

View File

@ -36,6 +36,7 @@ export interface ModFormState {
/** Category labels for category search */ /** Category labels for category search */
lTags: string[] lTags: string[]
downloadUrls: DownloadUrl[] downloadUrls: DownloadUrl[]
published_at: number
} }
export interface DownloadUrl { export interface DownloadUrl {
@ -49,7 +50,6 @@ export interface DownloadUrl {
export interface ModDetails extends Omit<ModFormState, 'tags'> { export interface ModDetails extends Omit<ModFormState, 'tags'> {
id: string id: string
published_at: number
edited_at: number edited_at: number
author: string author: string
tags: string[] tags: string[]
@ -67,3 +67,17 @@ export interface ModPageLoaderResult {
isBlocked: boolean isBlocked: boolean
isRepost: 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
}

View File

@ -119,6 +119,7 @@ export const initializeFormState = (
): ModFormState => ({ ): ModFormState => ({
dTag: existingModData?.dTag || '', dTag: existingModData?.dTag || '',
aTag: existingModData?.aTag || '', aTag: existingModData?.aTag || '',
published_at: existingModData?.published_at || 0,
rTag: existingModData?.rTag || window.location.host, rTag: existingModData?.rTag || window.location.host,
game: existingModData?.game || '', game: existingModData?.game || '',
title: existingModData?.title || '', title: existingModData?.title || '',