feat: blogs #118

Merged
enes merged 20 commits from feature/blogs into staging 2024-11-11 12:00:59 +00:00
14 changed files with 494 additions and 177 deletions
Showing only changes of commit d4148ed01d - Show all commits

28
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

@ -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[]>

View File

@ -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
View File

@ -0,0 +1 @@
export const BlogPage = () => <>WIP</>

View File

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

View File

@ -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,

23
src/types/blog.ts Normal file
View 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> {}

View File

@ -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'

View File

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