categories,18popup,clear.TextEditorSwap,GameCardHover #177
4276
package-lock.json
generated
4276
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,14 +11,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/lightning-tools": "5.0.3",
|
"@getalby/lightning-tools": "5.0.3",
|
||||||
|
"@mdxeditor/editor": "^3.20.0",
|
||||||
"@nostr-dev-kit/ndk": "2.10.0",
|
"@nostr-dev-kit/ndk": "2.10.0",
|
||||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||||
"@reduxjs/toolkit": "2.2.6",
|
"@reduxjs/toolkit": "2.2.6",
|
||||||
"@tiptap/core": "2.9.1",
|
|
||||||
"@tiptap/extension-image": "^2.9.1",
|
|
||||||
"@tiptap/extension-link": "2.9.1",
|
|
||||||
"@tiptap/react": "2.9.1",
|
|
||||||
"@tiptap/starter-kit": "2.9.1",
|
|
||||||
"@types/react-helmet": "^6.1.11",
|
"@types/react-helmet": "^6.1.11",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bech32": "2.0.0",
|
"bech32": "2.0.0",
|
||||||
@ -30,6 +26,7 @@
|
|||||||
"fslightbox-react": "1.7.6",
|
"fslightbox-react": "1.7.6",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"marked": "^14.1.3",
|
"marked": "^14.1.3",
|
||||||
|
"marked-directive": "^1.0.7",
|
||||||
"nostr-login": "1.5.2",
|
"nostr-login": "1.5.2",
|
||||||
"nostr-tools": "2.7.1",
|
"nostr-tools": "2.7.1",
|
||||||
"papaparse": "5.4.1",
|
"papaparse": "5.4.1",
|
||||||
|
@ -405,10 +405,22 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
|
|||||||
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
||||||
selectedCombinations.includes(childPath)
|
selectedCombinations.includes(childPath)
|
||||||
)
|
)
|
||||||
setIsIndeterminate(
|
const anyChildCombinationLinked = childPaths.some(
|
||||||
anyChildCombinationSelected && !selectedCombinations.includes(pathString)
|
(childPath) =>
|
||||||
|
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
|
||||||
)
|
)
|
||||||
}, [category, name, path, selectedCombinations, selectedSingles])
|
setIsIndeterminate(
|
||||||
|
(anyChildCombinationSelected || anyChildCombinationLinked) &&
|
||||||
|
!selectedCombinations.includes(pathString)
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
category,
|
||||||
|
linkedHierarchy,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
selectedCombinations,
|
||||||
|
selectedSingles
|
||||||
|
])
|
||||||
|
|
||||||
const handleSingleChange = () => {
|
const handleSingleChange = () => {
|
||||||
setIsSingleChecked(!isSingleChecked)
|
setIsSingleChecked(!isSingleChecked)
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import Link from '@tiptap/extension-link'
|
import React from 'react'
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import { Editor, EditorContent, useEditor } from '@tiptap/react'
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import '../styles/styles.css'
|
import '../styles/styles.css'
|
||||||
import '../styles/tiptap.scss'
|
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
label: string | React.ReactElement
|
label: string | React.ReactElement
|
||||||
description?: string
|
description?: string
|
||||||
type?: 'text' | 'textarea' | 'richtext'
|
type?: 'text' | 'textarea'
|
||||||
placeholder: string
|
placeholder: string
|
||||||
name: string
|
name: string
|
||||||
inputMode?: 'url'
|
inputMode?: 'url'
|
||||||
@ -48,11 +43,6 @@ export const InputField = React.memo(
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
></textarea>
|
></textarea>
|
||||||
) : type === 'richtext' ? (
|
|
||||||
<RichTextEditor
|
|
||||||
content={value}
|
|
||||||
updateContent={(content) => onChange(name, content)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@ -121,216 +111,8 @@ export const CheckboxField = React.memo(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
type RichTextEditorProps = {
|
|
||||||
content: string
|
|
||||||
updateContent: (updatedContent: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Link,
|
|
||||||
Image.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'IBMSMSMBSSPostImg'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
// Update the state when the editor content changes
|
|
||||||
updateContent(editor.getHTML())
|
|
||||||
},
|
|
||||||
content
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update editor content when the `content` prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && editor.getHTML() !== content) {
|
|
||||||
editor.commands.setContent(content, false)
|
|
||||||
}
|
|
||||||
}, [content, editor])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inputMain'>
|
|
||||||
{editor && (
|
|
||||||
<>
|
|
||||||
<MenuBar editor={editor} />
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuBarProps = {
|
|
||||||
editor: Editor
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MenuBar = ({ editor }: MenuBarProps) => {
|
|
||||||
const setLink = () => {
|
|
||||||
// Prompt the user to enter a URL
|
|
||||||
let url = prompt('URL')
|
|
||||||
|
|
||||||
// Check if the user provided a URL
|
|
||||||
if (url) {
|
|
||||||
// If the URL doesn't start with 'http://' or 'https://',
|
|
||||||
// prepend 'https://' to the URL
|
|
||||||
if (!/^(http|https):\/\//i.test(url)) {
|
|
||||||
url = `https://${url}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.chain().focus().setLink({ href: url }).run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no URL was provided (e.g., the user cancels the prompt),
|
|
||||||
// return false, indicating that the link was not set.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsetLink = () => editor.chain().focus().unsetLink().run()
|
|
||||||
|
|
||||||
const setImage = () => {
|
|
||||||
let url = prompt('URL')
|
|
||||||
if (url) {
|
|
||||||
if (!/^(http|https):\/\//i.test(url)) {
|
|
||||||
url = `https://${url}`
|
|
||||||
}
|
|
||||||
return editor.chain().focus().setImage({ src: url }).run()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons: MenuBarButtonProps[] = [
|
|
||||||
{
|
|
||||||
label: 'Bold',
|
|
||||||
disabled: !editor.can().chain().focus().toggleBold().run(),
|
|
||||||
isActive: editor.isActive('bold'),
|
|
||||||
onClick: () => editor.chain().focus().toggleBold().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Italic',
|
|
||||||
disabled: !editor.can().chain().focus().toggleItalic().run(),
|
|
||||||
isActive: editor.isActive('italic'),
|
|
||||||
onClick: () => editor.chain().focus().toggleItalic().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Strike',
|
|
||||||
disabled: !editor.can().chain().focus().toggleStrike().run(),
|
|
||||||
isActive: editor.isActive('strike'),
|
|
||||||
onClick: () => editor.chain().focus().toggleStrike().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clear marks',
|
|
||||||
onClick: () => editor.chain().focus().unsetAllMarks().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clear nodes',
|
|
||||||
onClick: () => editor.chain().focus().clearNodes().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Paragraph',
|
|
||||||
isActive: editor.isActive('paragraph'),
|
|
||||||
onClick: () => editor.chain().focus().setParagraph().run()
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
|
|
||||||
label: `H${level}`,
|
|
||||||
isActive: editor.isActive('heading', { level }),
|
|
||||||
onClick: () => editor.chain().focus().toggleHeading({ level }).run()
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
label: 'Bullet list',
|
|
||||||
isActive: editor.isActive('bulletList'),
|
|
||||||
onClick: () => editor.chain().focus().toggleBulletList().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ordered list',
|
|
||||||
isActive: editor.isActive('orderedList'),
|
|
||||||
onClick: () => editor.chain().focus().toggleOrderedList().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Code block',
|
|
||||||
isActive: editor.isActive('codeBlock'),
|
|
||||||
onClick: () => editor.chain().focus().toggleCodeBlock().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Blockquote',
|
|
||||||
isActive: editor.isActive('blockquote'),
|
|
||||||
onClick: () => editor.chain().focus().toggleBlockquote().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Link',
|
|
||||||
isActive: editor.isActive('link'),
|
|
||||||
onClick: editor.isActive('link') ? unsetLink : setLink
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Image',
|
|
||||||
isActive: editor.isActive('image'),
|
|
||||||
onClick: setImage
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Horizontal rule',
|
|
||||||
onClick: () => editor.chain().focus().setHorizontalRule().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hard break',
|
|
||||||
onClick: () => editor.chain().focus().setHardBreak().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Undo',
|
|
||||||
disabled: !editor.can().chain().focus().undo().run(),
|
|
||||||
onClick: () => editor.chain().focus().undo().run()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Redo',
|
|
||||||
disabled: !editor.can().chain().focus().redo().run(),
|
|
||||||
onClick: () => editor.chain().focus().redo().run()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='control-group'>
|
|
||||||
<div className='button-group'>
|
|
||||||
{buttons.map(({ label, disabled, isActive, onClick }) => (
|
|
||||||
<MenuBarButton
|
|
||||||
key={label}
|
|
||||||
label={label}
|
|
||||||
disabled={disabled}
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuBarButtonProps {
|
|
||||||
label: string
|
|
||||||
isActive?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onClick: () => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const MenuBarButton = ({
|
|
||||||
label,
|
|
||||||
isActive = false,
|
|
||||||
disabled = false,
|
|
||||||
onClick
|
|
||||||
}: MenuBarButtonProps) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
|
|
||||||
type='button'
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
10
src/components/Markdown/Dialog.module.scss
Normal file
10
src/components/Markdown/Dialog.module.scss
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.formAction {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
137
src/components/Markdown/Editor.tsx
Normal file
137
src/components/Markdown/Editor.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
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'
|
||||||
|
import { ImageDialog } from './ImageDialog'
|
||||||
|
import { LinkDialog } from './LinkDialog'
|
||||||
|
|
||||||
|
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({
|
||||||
|
ImageDialog: ImageDialog
|
||||||
|
}),
|
||||||
|
tablePlugin(),
|
||||||
|
linkPlugin(),
|
||||||
|
linkDialogPlugin({
|
||||||
|
LinkDialog: LinkDialog
|
||||||
|
}),
|
||||||
|
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
|
||||||
|
)
|
166
src/components/Markdown/ImageDialog.tsx
Normal file
166
src/components/Markdown/ImageDialog.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||||
|
import {
|
||||||
|
closeImageDialog$,
|
||||||
|
editorRootElementRef$,
|
||||||
|
imageDialogState$,
|
||||||
|
imageUploadHandler$,
|
||||||
|
saveImage$
|
||||||
|
} from '@mdxeditor/editor'
|
||||||
|
import styles from './Dialog.module.scss'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
interface ImageFormFields {
|
||||||
|
src: string
|
||||||
|
title: string
|
||||||
|
altText: string
|
||||||
|
file: FileList
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageDialog: React.FC = () => {
|
||||||
|
const [state, editorRootElementRef, imageUploadHandler] = useCellValues(
|
||||||
|
imageDialogState$,
|
||||||
|
editorRootElementRef$,
|
||||||
|
imageUploadHandler$
|
||||||
|
)
|
||||||
|
const saveImage = usePublisher(saveImage$)
|
||||||
|
const closeImageDialog = usePublisher(closeImageDialog$)
|
||||||
|
const { register, handleSubmit, setValue, reset } = useForm<ImageFormFields>({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
|
||||||
|
values: state.type === 'editing' ? (state.initialValues as any) : {}
|
||||||
|
})
|
||||||
|
const [open, setOpen] = useState(state.type !== 'inactive')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(state.type !== 'inactive')
|
||||||
|
}, [state.type])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
closeImageDialog()
|
||||||
|
reset({ src: '', title: '', altText: '' })
|
||||||
|
}
|
||||||
|
}, [closeImageDialog, open, reset])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
if (!editorRootElementRef?.current) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className='popUpMain'>
|
||||||
|
<div className='ContainerMain'>
|
||||||
|
<div className='popUpMainCardWrapper'>
|
||||||
|
<div className='popUpMainCard popUpMainCardQR'>
|
||||||
|
<div className='popUpMainCardTop'>
|
||||||
|
<div className='popUpMainCardTopInfo'>
|
||||||
|
<h3>Add an image</h3>
|
||||||
|
</div>
|
||||||
|
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-96 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='pUMCB_Zaps'>
|
||||||
|
<form
|
||||||
|
className='pUMCB_ZapsInside'
|
||||||
|
onSubmit={(e) => {
|
||||||
|
void handleSubmit(saveImage)(e)
|
||||||
|
reset({ src: '', title: '', altText: '' })
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUploadHandler === null ? (
|
||||||
|
<input type='hidden' accept='image/*' {...register('file')} />
|
||||||
|
) : (
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='file'>
|
||||||
|
Upload an image from your device:
|
||||||
|
</label>
|
||||||
|
<input type='file' accept='image/*' {...register('file')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='src'>
|
||||||
|
{imageUploadHandler !== null
|
||||||
|
? 'Or add an image from an URL:'
|
||||||
|
: 'Add an image from an URL:'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
defaultValue={
|
||||||
|
state.type === 'editing'
|
||||||
|
? state.initialValues.src ?? ''
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
className='inputMain'
|
||||||
|
size={40}
|
||||||
|
autoFocus
|
||||||
|
{...register('src')}
|
||||||
|
onChange={(e) => setValue('src', e.currentTarget.value)}
|
||||||
|
placeholder={'Paste an image src'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='alt'>
|
||||||
|
Alt:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
{...register('altText')}
|
||||||
|
className='inputMain'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='title'>
|
||||||
|
Title:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
{...register('title')}
|
||||||
|
className='inputMain'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formAction}>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
title={'Save'}
|
||||||
|
aria-label={'Save'}
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='reset'
|
||||||
|
title={'Cancel'}
|
||||||
|
aria-label={'Cancel'}
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
editorRootElementRef?.current
|
||||||
|
)
|
||||||
|
}
|
306
src/components/Markdown/LinkDialog.tsx
Normal file
306
src/components/Markdown/LinkDialog.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import * as Popover from '@radix-ui/react-popover'
|
||||||
|
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
activeEditor$,
|
||||||
|
editorRootElementRef$,
|
||||||
|
iconComponentFor$,
|
||||||
|
cancelLinkEdit$,
|
||||||
|
linkDialogState$,
|
||||||
|
onWindowChange$,
|
||||||
|
removeLink$,
|
||||||
|
switchFromPreviewToLinkEdit$,
|
||||||
|
updateLink$,
|
||||||
|
ClickLinkCallback
|
||||||
|
} from '@mdxeditor/editor'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { Cell, useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||||
|
import styles from './Dialog.module.scss'
|
||||||
|
|
||||||
|
interface LinkEditFormProps {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
onSubmit: (link: { url: string; title: string }) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkFormFields {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkEditForm({
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
onSubmit,
|
||||||
|
onCancel
|
||||||
|
}: LinkEditFormProps) {
|
||||||
|
const { register, handleSubmit, setValue } = useForm<LinkFormFields>({
|
||||||
|
values: {
|
||||||
|
url,
|
||||||
|
title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='pUMCB_Zaps'>
|
||||||
|
<form
|
||||||
|
className='pUMCB_ZapsInside'
|
||||||
|
onSubmit={(e) => {
|
||||||
|
void handleSubmit(onSubmit)(e)
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
onReset={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCancel()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='file'>
|
||||||
|
URL:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
defaultValue={url}
|
||||||
|
className='inputMain'
|
||||||
|
size={40}
|
||||||
|
autoFocus
|
||||||
|
{...register('url')}
|
||||||
|
onChange={(e) => setValue('url', e.currentTarget.value)}
|
||||||
|
placeholder={'Paste an URL'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label className='form-label labelMain' htmlFor='link-title'>
|
||||||
|
Title:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='link-title'
|
||||||
|
className='inputMain'
|
||||||
|
size={40}
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formAction}>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
title={'Set URL'}
|
||||||
|
aria-label={'Set URL'}
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='reset'
|
||||||
|
title={'Cancel change'}
|
||||||
|
aria-label={'Cancel change'}
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onClickLinkCallback$ = Cell<ClickLinkCallback | null>(null)
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export const LinkDialog = () => {
|
||||||
|
const [
|
||||||
|
editorRootElementRef,
|
||||||
|
activeEditor,
|
||||||
|
iconComponentFor,
|
||||||
|
linkDialogState,
|
||||||
|
onClickLinkCallback
|
||||||
|
] = useCellValues(
|
||||||
|
editorRootElementRef$,
|
||||||
|
activeEditor$,
|
||||||
|
iconComponentFor$,
|
||||||
|
linkDialogState$,
|
||||||
|
onClickLinkCallback$
|
||||||
|
)
|
||||||
|
const publishWindowChange = usePublisher(onWindowChange$)
|
||||||
|
const updateLink = usePublisher(updateLink$)
|
||||||
|
const cancelLinkEdit = usePublisher(cancelLinkEdit$)
|
||||||
|
const switchFromPreviewToLinkEdit = usePublisher(switchFromPreviewToLinkEdit$)
|
||||||
|
const removeLink = usePublisher(removeLink$)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
activeEditor?.getEditorState().read(() => {
|
||||||
|
publishWindowChange(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('scroll', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('scroll', update)
|
||||||
|
}
|
||||||
|
}, [activeEditor, publishWindowChange])
|
||||||
|
|
||||||
|
const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const theRect = linkDialogState.rectangle
|
||||||
|
|
||||||
|
const urlIsExternal =
|
||||||
|
linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={linkDialogState.type !== 'inactive'}>
|
||||||
|
<Popover.Anchor
|
||||||
|
data-visible={linkDialogState.type === 'edit'}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${theRect?.top ?? 0}px`,
|
||||||
|
left: `${theRect?.left ?? 0}px`,
|
||||||
|
width: `${theRect?.width ?? 0}px`,
|
||||||
|
height: `${theRect?.height ?? 0}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover.Portal container={editorRootElementRef?.current}>
|
||||||
|
<Popover.Content
|
||||||
|
sideOffset={5}
|
||||||
|
onOpenAutoFocus={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
key={linkDialogState.linkNodeKey}
|
||||||
|
className={[
|
||||||
|
'popUpMainCard',
|
||||||
|
...(linkDialogState.type === 'edit' ? [styles.wrapper] : [])
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{linkDialogState.type === 'edit' && (
|
||||||
|
<LinkEditForm
|
||||||
|
url={linkDialogState.url}
|
||||||
|
title={linkDialogState.title}
|
||||||
|
onSubmit={updateLink}
|
||||||
|
onCancel={cancelLinkEdit.bind(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkDialogState.type === 'preview' && (
|
||||||
|
<>
|
||||||
|
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||||
|
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||||
|
<p className='IBMSMSMSSS_Author_Top_Address'>
|
||||||
|
<a
|
||||||
|
className={styles.linkDialogPreviewAnchor}
|
||||||
|
href={linkDialogState.url}
|
||||||
|
{...(urlIsExternal
|
||||||
|
? { target: '_blank', rel: 'noreferrer' }
|
||||||
|
: {})}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
onClickLinkCallback !== null &&
|
||||||
|
typeof onClickLinkCallback === 'function'
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
onClickLinkCallback(linkDialogState.url)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
urlIsExternal
|
||||||
|
? `Open ${linkDialogState.url} in new window`
|
||||||
|
: linkDialogState.url
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{linkDialogState.url}</span>
|
||||||
|
{urlIsExternal && iconComponentFor('open_in_new')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||||
|
<div
|
||||||
|
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||||
|
onClick={() => {
|
||||||
|
switchFromPreviewToLinkEdit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root open={copyUrlTooltipOpen}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<div
|
||||||
|
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||||
|
onClick={() => {
|
||||||
|
void window.navigator.clipboard
|
||||||
|
.writeText(linkDialogState.url)
|
||||||
|
.then(() => {
|
||||||
|
setCopyUrlTooltipOpen(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopyUrlTooltipOpen(false)
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal container={editorRootElementRef?.current}>
|
||||||
|
<Tooltip.Content sideOffset={5}>
|
||||||
|
{'Copied!'}
|
||||||
|
<Tooltip.Arrow />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||||
|
onClick={() => {
|
||||||
|
removeLink()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 640 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z' />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Popover.Arrow />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
65
src/components/Markdown/PlainTextCodeEditorDescriptor.tsx
Normal file
65
src/components/Markdown/PlainTextCodeEditorDescriptor.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
CodeBlockEditorDescriptor,
|
||||||
|
useCodeBlockEditorContext
|
||||||
|
} from '@mdxeditor/editor'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
match: (_language, _meta) => true,
|
||||||
|
priority: 0,
|
||||||
|
Editor: ({ code, focusEmitter }) => {
|
||||||
|
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
|
||||||
|
const defaultValue = useRef(code)
|
||||||
|
const codeRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
const handleInput = useCallback(
|
||||||
|
(e: React.FormEvent<HTMLElement>) => {
|
||||||
|
setCode(e.currentTarget.innerHTML)
|
||||||
|
},
|
||||||
|
[setCode]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (codeRef.current) {
|
||||||
|
codeRef.current.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focusEmitter.subscribe(handleFocus)
|
||||||
|
}, [focusEmitter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentRef = codeRef.current
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||||
|
if (codeRef.current?.textContent === '') {
|
||||||
|
parentEditor.update(() => {
|
||||||
|
lexicalNode.selectNext()
|
||||||
|
lexicalNode.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentRef) {
|
||||||
|
currentRef.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (currentRef) {
|
||||||
|
currentRef.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [lexicalNode, parentEditor])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre>
|
||||||
|
<code
|
||||||
|
ref={codeRef}
|
||||||
|
contentEditable={true}
|
||||||
|
onInput={handleInput}
|
||||||
|
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
37
src/components/Markdown/Viewer.tsx
Normal file
37
src/components/Markdown/Viewer.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { createDirectives, presetDirectiveConfigs } from 'marked-directive'
|
||||||
|
import { youtubeDirective } from './YoutubeDirective'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface ViewerProps {
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Viewer = ({ markdown }: ViewerProps) => {
|
||||||
|
const html = useMemo(() => {
|
||||||
|
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
|
||||||
|
if (node.nodeName && node.nodeName === 'IFRAME') {
|
||||||
|
const src = node.attributes.getNamedItem('src')
|
||||||
|
if (!(src && src.value.startsWith('https://www.youtube.com/embed/'))) {
|
||||||
|
node.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(
|
||||||
|
marked
|
||||||
|
.use(createDirectives([...presetDirectiveConfigs, youtubeDirective]))
|
||||||
|
.parse(`${markdown}`, {
|
||||||
|
async: false
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
ADD_TAGS: ['iframe']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [markdown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||||
|
)
|
||||||
|
}
|
36
src/components/Markdown/YoutubeButton.tsx
Normal file
36
src/components/Markdown/YoutubeButton.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { LeafDirective } from 'mdast-util-directive'
|
||||||
|
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
|
||||||
|
|
||||||
|
function getId(url: string) {
|
||||||
|
const regExp =
|
||||||
|
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
|
||||||
|
const match = url.match(regExp)
|
||||||
|
return match && match[7].length == 11 ? match[7] : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const YouTubeButton = () => {
|
||||||
|
const insertDirective = usePublisher(insertDirective$)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogButton
|
||||||
|
tooltipTitle='Insert Youtube video'
|
||||||
|
submitButtonTitle='Insert video'
|
||||||
|
dialogInputPlaceholder='Paste the youtube video URL'
|
||||||
|
buttonContent='YT'
|
||||||
|
onSubmit={(url) => {
|
||||||
|
const videoId = getId(url)
|
||||||
|
if (videoId) {
|
||||||
|
insertDirective({
|
||||||
|
name: 'youtube',
|
||||||
|
type: 'leafDirective',
|
||||||
|
|
||||||
|
attributes: { id: videoId },
|
||||||
|
children: []
|
||||||
|
} as LeafDirective)
|
||||||
|
} else {
|
||||||
|
alert('Invalid YouTube URL')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
28
src/components/Markdown/YoutubeDirective.tsx
Normal file
28
src/components/Markdown/YoutubeDirective.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { type DirectiveConfig } from 'marked-directive'
|
||||||
|
|
||||||
|
// defines `:youtube` directive
|
||||||
|
export const youtubeDirective: DirectiveConfig = {
|
||||||
|
level: 'block',
|
||||||
|
marker: '::',
|
||||||
|
renderer(token) {
|
||||||
|
//https://www.youtube.com/embed/<VIDEO_ID>
|
||||||
|
//::youtube{#<VIDEO_ID>}
|
||||||
|
let vid: string = ''
|
||||||
|
if (token.attrs && token.meta.name === 'youtube') {
|
||||||
|
for (const attr in token.attrs) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
|
||||||
|
attr.startsWith('#')
|
||||||
|
) {
|
||||||
|
vid = attr.replace('#', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vid) {
|
||||||
|
return `<iframe title="Video embed" width="560" height="315" src="https://www.youtube.com/embed/${vid}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
59
src/components/Markdown/YoutubeDirectiveDescriptor.tsx
Normal file
59
src/components/Markdown/YoutubeDirectiveDescriptor.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { LeafDirective } from 'mdast-util-directive'
|
||||||
|
import { DirectiveDescriptor } from '@mdxeditor/editor'
|
||||||
|
|
||||||
|
interface YoutubeDirectiveNode extends LeafDirective {
|
||||||
|
name: 'youtube'
|
||||||
|
attributes: { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
|
||||||
|
{
|
||||||
|
name: 'youtube',
|
||||||
|
type: 'leafDirective',
|
||||||
|
testNode(node) {
|
||||||
|
return node.name === 'youtube'
|
||||||
|
},
|
||||||
|
attributes: ['id'],
|
||||||
|
hasChildren: false,
|
||||||
|
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title='delete'
|
||||||
|
className='btnMain'
|
||||||
|
onClick={() => {
|
||||||
|
parentEditor.update(() => {
|
||||||
|
lexicalNode.selectNext()
|
||||||
|
lexicalNode.remove()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 448 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
>
|
||||||
|
<path d='M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<iframe
|
||||||
|
width='560'
|
||||||
|
height='315'
|
||||||
|
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
|
||||||
|
title='YouTube video player'
|
||||||
|
frameBorder='0'
|
||||||
|
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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'>
|
||||||
placeholder="Here's what this mod is all about"
|
<Editor
|
||||||
name='body'
|
ref={editorRef}
|
||||||
value={formState.body}
|
markdown={markdown}
|
||||||
error={formErrors.body}
|
placeholder="Here's what this mod is all about"
|
||||||
onChange={handleInputChange}
|
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
|
<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 = {
|
||||||
|
@ -111,7 +111,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ndk = useMemo(() => {
|
const ndk = useMemo(() => {
|
||||||
localStorage.setItem('debug', '*')
|
localStorage.removeItem('debug')
|
||||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
||||||
dexieAdapter.locking = true
|
dexieAdapter.locking = true
|
||||||
const ndk = new NDK({
|
const ndk = new NDK({
|
||||||
|
@ -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}
|
||||||
|
@ -5,12 +5,6 @@ import {
|
|||||||
useNavigation,
|
useNavigation,
|
||||||
useSubmit
|
useSubmit
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import { marked } from 'marked'
|
|
||||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { ProfileSection } from 'components/ProfileSection'
|
import { ProfileSection } from 'components/ProfileSection'
|
||||||
import { Comments } from 'components/comment'
|
import { Comments } from 'components/comment'
|
||||||
@ -23,6 +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 { Viewer } from 'components/Markdown/Viewer'
|
||||||
|
|
||||||
const BLOG_REPORT_REASONS = [
|
const BLOG_REPORT_REASONS = [
|
||||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||||
@ -42,25 +37,6 @@ export const BlogPage = () => {
|
|||||||
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const [commentCount, setCommentCount] = useState(0)
|
const [commentCount, setCommentCount] = useState(0)
|
||||||
const html = marked.parse(blog?.content || '', { async: false })
|
|
||||||
const sanitized = DOMPurify.sanitize(html)
|
|
||||||
const editor = useEditor(
|
|
||||||
{
|
|
||||||
content: sanitized,
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Link,
|
|
||||||
Image.configure({
|
|
||||||
inline: true,
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'IBMSMSMBSSPostImg'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
editable: false
|
|
||||||
},
|
|
||||||
[sanitized]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [showReportPopUp, setShowReportPopUp] = useState<number>()
|
const [showReportPopUp, setShowReportPopUp] = useState<number>()
|
||||||
useBodyScrollDisable(!!showReportPopUp)
|
useBodyScrollDisable(!!showReportPopUp)
|
||||||
@ -266,7 +242,10 @@ export const BlogPage = () => {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBSSPostBody'>
|
<div className='IBMSMSMBSSPostBody'>
|
||||||
<EditorContent editor={editor} />
|
<Viewer
|
||||||
|
key={blog.id}
|
||||||
|
markdown={blog?.content || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBSSTags'>
|
<div className='IBMSMSMBSSTags'>
|
||||||
{blog.nsfw && (
|
{blog.nsfw && (
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -10,7 +10,6 @@ import {
|
|||||||
now,
|
now,
|
||||||
parseFormData
|
parseFormData
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
import TurndownService from 'turndown'
|
|
||||||
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
|
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||||
@ -57,9 +56,8 @@ export const writeRouteAction =
|
|||||||
// Return earily if there are any errors
|
// Return earily if there are any errors
|
||||||
if (Object.keys(formErrors).length) return formErrors
|
if (Object.keys(formErrors).length) return formErrors
|
||||||
|
|
||||||
// Get the markdown from the html
|
// Get the markdown from formData
|
||||||
const turndownService = new TurndownService()
|
const content = decodeURIComponent(formSubmit.content!)
|
||||||
const content = turndownService.turndown(formSubmit.content!)
|
|
||||||
|
|
||||||
// Check if we are editing or this is a new blog
|
// Check if we are editing or this is a new blog
|
||||||
const { naddr } = params
|
const { naddr } = params
|
||||||
@ -154,11 +152,7 @@ const validateFormData = async (
|
|||||||
errors.title = 'Title field can not be empty'
|
errors.title = 'Title field can not be empty'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!formData.content || formData.content.trim() === '') {
|
||||||
!formData.content ||
|
|
||||||
formData.content.trim() === '' ||
|
|
||||||
formData.content.trim() === '<p></p>'
|
|
||||||
) {
|
|
||||||
errors.content = 'Content field can not be empty'
|
errors.content = 'Content field can not be empty'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,8 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CheckboxFieldUncontrolled,
|
CheckboxFieldUncontrolled,
|
||||||
InputError,
|
InputError,
|
||||||
InputFieldUncontrolled,
|
InputFieldUncontrolled
|
||||||
MenuBar
|
|
||||||
} from '../../components/Inputs'
|
} from '../../components/Inputs'
|
||||||
import { ProfileSection } from '../../components/ProfileSection'
|
import { ProfileSection } from '../../components/ProfileSection'
|
||||||
import { useAppSelector } from '../../hooks'
|
import { useAppSelector } from '../../hooks'
|
||||||
@ -18,13 +17,8 @@ import '../../styles/innerPage.css'
|
|||||||
import '../../styles/styles.css'
|
import '../../styles/styles.css'
|
||||||
import '../../styles/write.css'
|
import '../../styles/write.css'
|
||||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { marked } from 'marked'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import { AlertPopup } from 'components/AlertPopup'
|
import { AlertPopup } from 'components/AlertPopup'
|
||||||
|
import { Editor, EditorRef } from 'components/Markdown/Editor'
|
||||||
|
|
||||||
export const WritePage = () => {
|
export const WritePage = () => {
|
||||||
const userState = useAppSelector((state) => state.user)
|
const userState = useAppSelector((state) => state.user)
|
||||||
@ -34,27 +28,9 @@ export const WritePage = () => {
|
|||||||
|
|
||||||
const blog = data?.blog
|
const blog = data?.blog
|
||||||
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
|
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
|
||||||
const html = marked.parse(blog?.content || '', { async: false })
|
const [content, setContent] = useState(blog?.content || '')
|
||||||
const sanitized = DOMPurify.sanitize(html)
|
|
||||||
const [content, setContent] = useState<string>(sanitized)
|
|
||||||
const editor = useEditor({
|
|
||||||
content: content,
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Link,
|
|
||||||
Image.configure({
|
|
||||||
inline: true,
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'IBMSMSMBSSPostImg'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
setContent(editor.getHTML())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
||||||
@ -65,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,19 +75,28 @@ export const WritePage = () => {
|
|||||||
defaultValue={blog?.title}
|
defaultValue={blog?.title}
|
||||||
error={formErrors?.title}
|
error={formErrors?.title}
|
||||||
/>
|
/>
|
||||||
{editor && (
|
<div className='inputLabelWrapperMain'>
|
||||||
<div className='inputLabelWrapperMain'>
|
<label className='form-label labelMain'>Content</label>
|
||||||
<label className='form-label labelMain'>Content</label>
|
<div className='inputMain'>
|
||||||
<div className='inputMain'>
|
<Editor
|
||||||
<MenuBar editor={editor} />
|
ref={editorRef}
|
||||||
<EditorContent editor={editor} />
|
markdown={content}
|
||||||
</div>
|
onChange={(md) => {
|
||||||
{typeof formErrors?.content !== 'undefined' && (
|
setContent(md)
|
||||||
<InputError message={formErrors?.content} />
|
}}
|
||||||
)}
|
/>
|
||||||
<input name='content' hidden value={content} readOnly />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{typeof formErrors?.content !== 'undefined' && (
|
||||||
|
<InputError message={formErrors?.content} />
|
||||||
|
)}
|
||||||
|
{/* encode to keep the markdown formatting */}
|
||||||
|
<input
|
||||||
|
name='content'
|
||||||
|
hidden
|
||||||
|
value={encodeURIComponent(content)}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<InputFieldUncontrolled
|
<InputFieldUncontrolled
|
||||||
label='Featured Image URL'
|
label='Featured Image URL'
|
||||||
name='image'
|
name='image'
|
||||||
|
@ -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,
|
||||||
|
152
src/styles/mdxEditor.scss
Normal file
152
src/styles/mdxEditor.scss
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
.editor,
|
||||||
|
.viewer {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
> {
|
||||||
|
p {
|
||||||
|
margin: 5px 0 10px 0;
|
||||||
|
}
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||||
|
|
||||||
|
li p {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 15px 0 15px 0;
|
||||||
|
border-bottom: solid 1px rgb(255 255 255 / 10%);
|
||||||
|
padding: 0px 0 10px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-radius: 0 10px 10px 0;
|
||||||
|
border-left: solid 6px rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 25px;
|
||||||
|
background: #232323;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: mediumpurple;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--black);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25em 0.3em;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:empty::before {
|
||||||
|
content: '\00A0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--black);
|
||||||
|
color: var(--white);
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #00000030;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: solid 2px rebeccapurple;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
background: #232323;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
--basePageBg: var(--slate-3);
|
||||||
|
padding-top: 10px;
|
||||||
|
min-height: 75px;
|
||||||
|
}
|
||||||
|
.viewer {
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
& > tbody > tr > td,
|
||||||
|
& > thead > tr > th {
|
||||||
|
border: 1px solid #e0e1e6;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
& > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > tbody > tr > td,
|
||||||
|
& > thead > tr > th {
|
||||||
|
[align='left'] {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
[align='center'] {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
[align='right'] {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:empty::before {
|
||||||
|
content: '\00A0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mdxeditor {
|
||||||
|
--baseBg: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.mdxeditor,
|
||||||
|
.mdxeditor-popup-container {
|
||||||
|
--basePageBg: var(--slate-3);
|
||||||
|
}
|
@ -1,104 +0,0 @@
|
|||||||
/* Basic editor styles */
|
|
||||||
.tiptap {
|
|
||||||
/* List styles */
|
|
||||||
p {
|
|
||||||
margin: 5px 0px;
|
|
||||||
}
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
padding: 0 1rem;
|
|
||||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
|
||||||
|
|
||||||
li p {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
margin-bottom: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Heading styles */
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
line-height: 1.1;
|
|
||||||
margin: 10px 0px;
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: var(--purple-light); // todo: fix the color
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
color: var(--black);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.25em 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--black); // todo: fix the color
|
|
||||||
color: var(--white);
|
|
||||||
font-family: 'JetBrainsMono', monospace;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: #00000030;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: solid 2px rebeccapurple;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-radius: 0 10px 10px 0;
|
|
||||||
border-left: solid 6px rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 25px;
|
|
||||||
background: #232323;
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar Styling */
|
|
||||||
.control-group {
|
|
||||||
padding: 5px 0px 15px 0px;
|
|
||||||
border-radius: 0px;
|
|
||||||
border-bottom: solid 1px rgb(255 255 255 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror {
|
|
||||||
min-height: 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnMain.btnMainTipTap {
|
|
||||||
padding: 5px 10px;
|
|
||||||
height: 35px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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 || '',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user