feat: blogs #118
28
package-lock.json
generated
28
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "degmods.com",
|
"name": "degmods.com",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0-alpha-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "degmods.com",
|
"name": "degmods.com",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0-alpha-1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/lightning-tools": "5.0.3",
|
"@getalby/lightning-tools": "5.0.3",
|
||||||
"@nostr-dev-kit/ndk": "2.10.0",
|
"@nostr-dev-kit/ndk": "2.10.0",
|
||||||
@ -39,6 +39,7 @@
|
|||||||
"react-toastify": "10.0.5",
|
"react-toastify": "10.0.5",
|
||||||
"react-window": "1.8.10",
|
"react-window": "1.8.10",
|
||||||
"swiper": "11.1.11",
|
"swiper": "11.1.11",
|
||||||
|
"turndown": "^7.2.0",
|
||||||
"uuid": "10.0.0",
|
"uuid": "10.0.0",
|
||||||
"webln": "0.3.2"
|
"webln": "0.3.2"
|
||||||
},
|
},
|
||||||
@ -51,6 +52,7 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-window": "1.8.8",
|
"@types/react-window": "1.8.8",
|
||||||
|
"@types/turndown": "^5.0.5",
|
||||||
"@types/uuid": "10.0.0",
|
"@types/uuid": "10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
"@typescript-eslint/parser": "^7.13.1",
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
@ -1038,6 +1040,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mixmark-io/domino": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||||
@ -2032,6 +2040,13 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/turndown": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
||||||
@ -5149,6 +5164,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz",
|
||||||
"integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw=="
|
"integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/turndown": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type": {
|
"node_modules/type": {
|
||||||
"version": "2.7.3",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
"react-toastify": "10.0.5",
|
"react-toastify": "10.0.5",
|
||||||
"react-window": "1.8.10",
|
"react-window": "1.8.10",
|
||||||
"swiper": "11.1.11",
|
"swiper": "11.1.11",
|
||||||
|
"turndown": "^7.2.0",
|
||||||
"uuid": "10.0.0",
|
"uuid": "10.0.0",
|
||||||
"webln": "0.3.2"
|
"webln": "0.3.2"
|
||||||
},
|
},
|
||||||
@ -53,6 +54,7 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-window": "1.8.8",
|
"@types/react-window": "1.8.8",
|
||||||
|
"@types/turndown": "^5.0.5",
|
||||||
"@types/uuid": "10.0.0",
|
"@types/uuid": "10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
"@typescript-eslint/parser": "^7.13.1",
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { router } from 'routes'
|
import { routerWithNdkContext } from 'routes'
|
||||||
|
import { useNDKContext } from 'hooks'
|
||||||
import './styles/styles.css'
|
import './styles/styles.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const ndkContext = useNDKContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Find the element with id 'root'
|
// Find the element with id 'root'
|
||||||
const rootElement = document.getElementById('root')
|
const rootElement = document.getElementById('root')
|
||||||
@ -21,7 +24,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <RouterProvider router={router} />
|
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
@ -298,7 +298,55 @@ const MenuBarButton = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
|
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Uncontrolled input component with design classes, label, description and error support
|
||||||
|
*
|
||||||
|
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
||||||
|
* @param label
|
||||||
|
* @param description
|
||||||
|
* @param error
|
||||||
|
*
|
||||||
|
* @see {@link React.ComponentProps<'input'>}
|
||||||
|
*/
|
||||||
|
export const InputFieldUncontrolled = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
...rest
|
||||||
|
}: InputFieldUncontrolledProps) => (
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||||
|
<input className='inputMain' {...rest} />
|
||||||
|
{error && <InputError message={error} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckboxFieldUncontrolled = ({
|
||||||
|
label,
|
||||||
|
...rest
|
||||||
|
}: CheckboxFieldUncontrolledProps) => (
|
||||||
|
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||||
|
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
@ -32,7 +32,7 @@ type FetchModsOptions = {
|
|||||||
author?: string
|
author?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NDKContextType {
|
export interface NDKContextType {
|
||||||
ndk: NDK
|
ndk: NDK
|
||||||
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
|
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
|
||||||
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
|
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { NDKContext } from 'contexts/NDKContext'
|
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
|
||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
|
|
||||||
export const useNDKContext = () => {
|
export const useNDKContext = () => {
|
||||||
@ -9,5 +9,5 @@ export const useNDKContext = () => {
|
|||||||
'NDKContext should not be used in out component tree hierarchy'
|
'NDKContext should not be used in out component tree hierarchy'
|
||||||
)
|
)
|
||||||
|
|
||||||
return { ...ndkContext }
|
return { ...ndkContext } as NDKContextType
|
||||||
}
|
}
|
||||||
|
1
src/pages/blog.tsx
Normal file
1
src/pages/blog.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const BlogPage = () => <>WIP</>
|
@ -1,75 +0,0 @@
|
|||||||
import { CheckboxField, InputField } from '../components/Inputs'
|
|
||||||
import { ProfileSection } from '../components/ProfileSection'
|
|
||||||
import { useAppSelector } from '../hooks'
|
|
||||||
import '../styles/innerPage.css'
|
|
||||||
import '../styles/styles.css'
|
|
||||||
import '../styles/write.css'
|
|
||||||
|
|
||||||
export const WritePage = () => {
|
|
||||||
const userState = useAppSelector((state) => state.user)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='InnerBodyMain'>
|
|
||||||
<div className='ContainerMain'>
|
|
||||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
|
||||||
<div className='IBMSMSplitMain'>
|
|
||||||
<div className='IBMSMSplitMainBigSide'>
|
|
||||||
<div className='IBMSMTitleMain'>
|
|
||||||
<h2 className='IBMSMTitleMainHeading'>
|
|
||||||
Write a blog post (WIP)
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className='IBMSMSMBS_Write'>
|
|
||||||
<InputField
|
|
||||||
label='Title'
|
|
||||||
placeholder=''
|
|
||||||
name='title'
|
|
||||||
value=''
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label='Body'
|
|
||||||
placeholder=''
|
|
||||||
name='body'
|
|
||||||
value=''
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label='Featured Image URL'
|
|
||||||
placeholder=''
|
|
||||||
name='imageUrl'
|
|
||||||
inputMode='url'
|
|
||||||
value=''
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label='Summary'
|
|
||||||
placeholder=''
|
|
||||||
name='summary'
|
|
||||||
type='textarea'
|
|
||||||
value=''
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
<CheckboxField
|
|
||||||
label='This mod not safe for work (NSFW)'
|
|
||||||
name='nsfw'
|
|
||||||
isChecked={false}
|
|
||||||
handleChange={() => {}}
|
|
||||||
type='stylized'
|
|
||||||
/>
|
|
||||||
<div className='IBMSMSMBS_WriteAction'>
|
|
||||||
<button className='btn btnMain' type='button'>
|
|
||||||
Publish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{userState.auth && userState.user?.pubkey && (
|
|
||||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
162
src/pages/write/action.tsx
Normal file
162
src/pages/write/action.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
|
import { ActionFunctionArgs, redirect } from 'react-router-dom'
|
||||||
|
import { getBlogPageRoute } from 'routes'
|
||||||
|
import { BlogFormErrors, BlogFormSubmit } from 'types'
|
||||||
|
import {
|
||||||
|
isReachable,
|
||||||
|
isValidImageUrl,
|
||||||
|
log,
|
||||||
|
LogType,
|
||||||
|
now,
|
||||||
|
parseFormData
|
||||||
|
} from 'utils'
|
||||||
|
import TurndownService from 'turndown'
|
||||||
|
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { store } from 'store'
|
||||||
|
|
||||||
|
export const writeRouteAction =
|
||||||
|
(ndkContext: NDKContextType) =>
|
||||||
|
async ({ params, request }: ActionFunctionArgs) => {
|
||||||
|
// Get the current state
|
||||||
|
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
|
||||||
|
const formData = await request.formData()
|
||||||
|
|
||||||
|
// Parse the the data
|
||||||
|
const formSubmit = parseFormData<BlogFormSubmit>(formData)
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
const formErrors = await validateFormData(formSubmit)
|
||||||
|
|
||||||
|
// Return earily if there are any errors
|
||||||
|
if (Object.keys(formErrors).length) return formErrors
|
||||||
|
|
||||||
|
// Get the markdown from the html
|
||||||
|
const turndownService = new TurndownService()
|
||||||
|
const content = turndownService.turndown(formSubmit.content!)
|
||||||
|
|
||||||
|
const uuid = uuidv4()
|
||||||
|
const currentTimeStamp = now()
|
||||||
|
|
||||||
|
const aTag = `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
||||||
|
const tTags = formSubmit
|
||||||
|
.tags!.toLowerCase()
|
||||||
|
.split(',')
|
||||||
|
.map((t) => ['t', t])
|
||||||
|
|
||||||
|
const unsignedEvent: UnsignedEvent = {
|
||||||
|
kind: kinds.LongFormArticle,
|
||||||
|
created_at: currentTimeStamp,
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
content: content,
|
||||||
|
tags: [
|
||||||
|
['d', uuid],
|
||||||
|
['a', aTag],
|
||||||
|
['r', window.location.host],
|
||||||
|
['published_at', currentTimeStamp.toString()],
|
||||||
|
['title', formSubmit.title!],
|
||||||
|
['image', formSubmit.image!],
|
||||||
|
['summary', formSubmit.summary!],
|
||||||
|
['nsfw', (formSubmit.nsfw === 'on').toString()],
|
||||||
|
...tTags
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedEvent = await window.nostr
|
||||||
|
?.signEvent(unsignedEvent)
|
||||||
|
.then((event) => event as Event)
|
||||||
|
|
||||||
|
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.')
|
||||||
|
return null
|
||||||
|
} 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(getBlogPageRoute(naddr))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(true, LogType.Error, 'Failed to sign the event!', error)
|
||||||
|
toast.error('Failed to sign the event!')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFormData = async (
|
||||||
|
formData: Partial<BlogFormSubmit>
|
||||||
|
): Promise<BlogFormErrors> => {
|
||||||
|
const errors: BlogFormErrors = {}
|
||||||
|
|
||||||
|
if (!formData.title || formData.title.trim() === '') {
|
||||||
|
errors.title = 'Title field can not be empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!formData.content ||
|
||||||
|
formData.content.trim() === '' ||
|
||||||
|
formData.content.trim() === '<p></p>'
|
||||||
|
) {
|
||||||
|
errors.content = 'Content field can not be empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.summary || formData.summary.trim() === '') {
|
||||||
|
errors.summary = 'Summary field can not be empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.tags || formData.tags.trim() === '') {
|
||||||
|
errors.tags = 'Tags field can not be empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.image || formData.image.trim() === '') {
|
||||||
|
errors.image = 'Image url field can not be empty'
|
||||||
|
} else if (
|
||||||
|
!isValidImageUrl(formData.image) ||
|
||||||
|
!(await isReachable(formData.image))
|
||||||
|
) {
|
||||||
|
errors.image = 'Image must be a valid and reachable'
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
105
src/pages/write/index.tsx
Normal file
105
src/pages/write/index.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Form, useActionData, useNavigation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
CheckboxFieldUncontrolled,
|
||||||
|
InputField,
|
||||||
|
InputFieldUncontrolled
|
||||||
|
} from '../../components/Inputs'
|
||||||
|
import { ProfileSection } from '../../components/ProfileSection'
|
||||||
|
import { useAppSelector } from '../../hooks'
|
||||||
|
import { BlogFormErrors } from 'types'
|
||||||
|
import '../../styles/innerPage.css'
|
||||||
|
import '../../styles/styles.css'
|
||||||
|
import '../../styles/write.css'
|
||||||
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
|
|
||||||
|
export const WritePage = () => {
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
const formErrors = useActionData() as BlogFormErrors
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const title = 'Submit a blog post'
|
||||||
|
|
||||||
|
const [content, setContent] = useState<string>('')
|
||||||
|
const handleContentChange = (_: string, value: string) => {
|
||||||
|
setContent(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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='Loading..' />
|
||||||
|
)}
|
||||||
|
{navigation.state === 'submitting' && (
|
||||||
|
<LoadingSpinner desc='Publishing blog to relays' />
|
||||||
|
)}
|
||||||
|
<Form className='IBMSMSMBS_Write' method='post'>
|
||||||
|
<InputFieldUncontrolled
|
||||||
|
label='Title'
|
||||||
|
name='title'
|
||||||
|
error={formErrors?.title}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label='Content'
|
||||||
|
name={'content'}
|
||||||
|
type='richtext'
|
||||||
|
placeholder='Blog content'
|
||||||
|
value={content}
|
||||||
|
error={formErrors?.content}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
/>
|
||||||
|
<input name='content' hidden value={content} readOnly />
|
||||||
|
<InputFieldUncontrolled
|
||||||
|
label='Featured Image URL'
|
||||||
|
name='image'
|
||||||
|
inputMode='url'
|
||||||
|
error={formErrors?.image}
|
||||||
|
/>
|
||||||
|
<InputFieldUncontrolled
|
||||||
|
label='Summary'
|
||||||
|
name='summary'
|
||||||
|
type='textarea'
|
||||||
|
error={formErrors?.summary}
|
||||||
|
/>
|
||||||
|
<InputFieldUncontrolled
|
||||||
|
label='Tags'
|
||||||
|
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
|
||||||
|
placeholder='Tags'
|
||||||
|
name='tags'
|
||||||
|
error={formErrors?.tags}
|
||||||
|
/>
|
||||||
|
<CheckboxFieldUncontrolled
|
||||||
|
label='This post is not safe for work (NSFW)'
|
||||||
|
name='nsfw'
|
||||||
|
/>
|
||||||
|
<div className='IBMSMSMBS_WriteAction'>
|
||||||
|
<button
|
||||||
|
className='btn btnMain'
|
||||||
|
type='submit'
|
||||||
|
disabled={
|
||||||
|
navigation.state === 'loading' ||
|
||||||
|
navigation.state === 'submitting'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navigation.state === 'submitting'
|
||||||
|
? 'Publishing...'
|
||||||
|
: 'Publish'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
{userState.auth && userState.user?.pubkey && (
|
||||||
|
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { createBrowserRouter } from 'react-router-dom'
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
import { Layout } from 'layout'
|
import { Layout } from 'layout'
|
||||||
import { SearchPage } from 'pages/search'
|
import { SearchPage } from '../pages/search'
|
||||||
import { AboutPage } from '../pages/about'
|
import { AboutPage } from '../pages/about'
|
||||||
import { BlogsPage } from '../pages/blogs'
|
import { BlogsPage } from '../pages/blogs'
|
||||||
import { GamesPage } from '../pages/games'
|
import { GamesPage } from '../pages/games'
|
||||||
@ -10,12 +11,14 @@ import { ModsPage } from '../pages/mods'
|
|||||||
import { ProfilePage } from '../pages/profile'
|
import { ProfilePage } from '../pages/profile'
|
||||||
import { SettingsPage } from '../pages/settings'
|
import { SettingsPage } from '../pages/settings'
|
||||||
import { SubmitModPage } from '../pages/submitMod'
|
import { SubmitModPage } from '../pages/submitMod'
|
||||||
|
import { GamePage } from '../pages/game'
|
||||||
|
import { NotFoundPage } from '../pages/404'
|
||||||
|
import { FeedLayout } from '../layout/feed'
|
||||||
|
import { FeedPage } from '../pages/feed'
|
||||||
|
import { NotificationsPage } from '../pages/notifications'
|
||||||
import { WritePage } from '../pages/write'
|
import { WritePage } from '../pages/write'
|
||||||
import { GamePage } from 'pages/game'
|
import { writeRouteAction } from '../pages/write/action'
|
||||||
import { NotFoundPage } from 'pages/404'
|
import { BlogPage } from 'pages/blog'
|
||||||
import { FeedLayout } from 'layout/feed'
|
|
||||||
import { FeedPage } from 'pages/feed'
|
|
||||||
import { NotificationsPage } from 'pages/notifications'
|
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
index: '/',
|
||||||
@ -25,7 +28,8 @@ export const appRoutes = {
|
|||||||
mods: '/mods',
|
mods: '/mods',
|
||||||
mod: '/mod/:naddr',
|
mod: '/mod/:naddr',
|
||||||
about: '/about',
|
about: '/about',
|
||||||
blog: '/blog',
|
blogs: '/blogs',
|
||||||
|
blog: '/blog/:naddr',
|
||||||
submitMod: '/submit-mod',
|
submitMod: '/submit-mod',
|
||||||
editMod: '/edit-mod/:naddr',
|
editMod: '/edit-mod/:naddr',
|
||||||
write: '/write',
|
write: '/write',
|
||||||
@ -48,10 +52,14 @@ export const getModPageRoute = (eventId: string) =>
|
|||||||
export const getModsEditPageRoute = (eventId: string) =>
|
export const getModsEditPageRoute = (eventId: string) =>
|
||||||
appRoutes.editMod.replace(':naddr', eventId)
|
appRoutes.editMod.replace(':naddr', eventId)
|
||||||
|
|
||||||
|
export const getBlogPageRoute = (eventId: string) =>
|
||||||
|
appRoutes.blog.replace(':naddr', eventId)
|
||||||
|
|
||||||
export const getProfilePageRoute = (nprofile: string) =>
|
export const getProfilePageRoute = (nprofile: string) =>
|
||||||
appRoutes.profile.replace(':nprofile', nprofile)
|
appRoutes.profile.replace(':nprofile', nprofile)
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const routerWithNdkContext = (context: NDKContextType) =>
|
||||||
|
createBrowserRouter([
|
||||||
{
|
{
|
||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
children: [
|
children: [
|
||||||
@ -80,9 +88,13 @@ export const router = createBrowserRouter([
|
|||||||
element: <AboutPage />
|
element: <AboutPage />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.blog,
|
path: appRoutes.blogs,
|
||||||
element: <BlogsPage />
|
element: <BlogsPage />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: appRoutes.blog,
|
||||||
|
element: <BlogPage />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.submitMod,
|
path: appRoutes.submitMod,
|
||||||
element: <SubmitModPage />
|
element: <SubmitModPage />
|
||||||
@ -93,7 +105,8 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.write,
|
path: appRoutes.write,
|
||||||
element: <WritePage />
|
element: <WritePage />,
|
||||||
|
action: writeRouteAction(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.search,
|
path: appRoutes.search,
|
||||||
@ -138,4 +151,4 @@ export const router = createBrowserRouter([
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
23
src/types/blog.ts
Normal file
23
src/types/blog.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface BlogDetails {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
nsfw: boolean
|
||||||
|
tags: string
|
||||||
|
|
||||||
|
id: string
|
||||||
|
author: string
|
||||||
|
published_at: number
|
||||||
|
edited_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogFormSubmit
|
||||||
|
extends Omit<
|
||||||
|
BlogDetails,
|
||||||
|
'nsfw' | 'id' | 'author' | 'published_at' | 'edited_at'
|
||||||
|
> {
|
||||||
|
nsfw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogFormErrors extends Partial<BlogFormSubmit> {}
|
@ -3,3 +3,4 @@ export * from './modsFilter'
|
|||||||
export * from './nostr'
|
export * from './nostr'
|
||||||
export * from './user'
|
export * from './user'
|
||||||
export * from './zap'
|
export * from './zap'
|
||||||
|
export * from './blog'
|
||||||
|
@ -146,3 +146,13 @@ export const scrollIntoView = (el: HTMLElement | null) => {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const parseFormData = <T>(formData: FormData) => {
|
||||||
|
const result: Partial<T> = {}
|
||||||
|
|
||||||
|
formData.forEach(
|
||||||
|
(value, key) => ((result as Record<string, unknown>)[key] = value as string)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user