diff --git a/package-lock.json b/package-lock.json
index 2666d9c..f29ff29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "degmods.com",
- "version": "0.0.0",
+ "version": "0.0.0-alpha-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "degmods.com",
- "version": "0.0.0",
+ "version": "0.0.0-alpha-1",
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
@@ -39,6 +39,7 @@
"react-toastify": "10.0.5",
"react-window": "1.8.10",
"swiper": "11.1.11",
+ "turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},
@@ -51,6 +52,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
+ "@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
@@ -1038,6 +1040,12 @@
"@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": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
@@ -2032,6 +2040,13 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"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": {
"version": "0.0.3",
"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",
"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": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
diff --git a/package.json b/package.json
index a3973cb..fa455fa 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"react-toastify": "10.0.5",
"react-window": "1.8.10",
"swiper": "11.1.11",
+ "turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},
@@ -53,6 +54,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
+ "@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
diff --git a/src/App.tsx b/src/App.tsx
index fe0ccd1..95183ab 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,9 +1,12 @@
import { RouterProvider } from 'react-router-dom'
import { useEffect } from 'react'
-import { router } from 'routes'
+import { routerWithNdkContext } from 'routes'
+import { useNDKContext } from 'hooks'
import './styles/styles.css'
function App() {
+ const ndkContext = useNDKContext()
+
useEffect(() => {
// Find the element with id 'root'
const rootElement = document.getElementById('root')
@@ -21,7 +24,7 @@ function App() {
}
}, [])
- return
+ return
}
export default App
diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx
index e40cc91..b21e544 100644
--- a/src/components/Inputs.tsx
+++ b/src/components/Inputs.tsx
@@ -298,7 +298,55 @@ const MenuBarButton = ({
onClick={onClick}
disabled={disabled}
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
+ type='button'
>
{label}
)
+
+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) => (
+
+
+ {description &&
{description}
}
+
+ {error &&
}
+
+)
+
+interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
+ label: string
+}
+
+export const CheckboxFieldUncontrolled = ({
+ label,
+ ...rest
+}: CheckboxFieldUncontrolledProps) => (
+
+
+
+
+)
diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx
index b76f3f2..23f672c 100644
--- a/src/contexts/NDKContext.tsx
+++ b/src/contexts/NDKContext.tsx
@@ -32,7 +32,7 @@ type FetchModsOptions = {
author?: string
}
-interface NDKContextType {
+export interface NDKContextType {
ndk: NDK
fetchMods: (opts: FetchModsOptions) => Promise
fetchEvents: (filter: NDKFilter) => Promise
diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts
index f7383df..20acf27 100644
--- a/src/hooks/useNDKContext.ts
+++ b/src/hooks/useNDKContext.ts
@@ -1,4 +1,4 @@
-import { NDKContext } from 'contexts/NDKContext'
+import { NDKContext, NDKContextType } from 'contexts/NDKContext'
import { useContext } from 'react'
export const useNDKContext = () => {
@@ -9,5 +9,5 @@ export const useNDKContext = () => {
'NDKContext should not be used in out component tree hierarchy'
)
- return { ...ndkContext }
+ return { ...ndkContext } as NDKContextType
}
diff --git a/src/pages/blog.tsx b/src/pages/blog.tsx
new file mode 100644
index 0000000..47c67f8
--- /dev/null
+++ b/src/pages/blog.tsx
@@ -0,0 +1 @@
+export const BlogPage = () => <>WIP>
diff --git a/src/pages/write.tsx b/src/pages/write.tsx
deleted file mode 100644
index 58813a5..0000000
--- a/src/pages/write.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
- Write a blog post (WIP)
-
-
-
-
{}}
- />
- {}}
- />
- {}}
- />
- {}}
- />
- {}}
- type='stylized'
- />
-
-
-
-
-
- {userState.auth && userState.user?.pubkey && (
-
- )}
-
-
-
-
- )
-}
diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx
new file mode 100644
index 0000000..4f21b7f
--- /dev/null
+++ b/src/pages/write/action.tsx
@@ -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(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
+): Promise => {
+ 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() === ''
+ ) {
+ 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
+}
diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx
new file mode 100644
index 0000000..a525509
--- /dev/null
+++ b/src/pages/write/index.tsx
@@ -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('')
+ const handleContentChange = (_: string, value: string) => {
+ setContent(value)
+ }
+
+ return (
+
+
+
+
+
+
+
{title}
+
+ {navigation.state === 'loading' && (
+
+ )}
+ {navigation.state === 'submitting' && (
+
+ )}
+
+
+ {userState.auth && userState.user?.pubkey && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 18e4d63..bc14e69 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,6 +1,7 @@
import { createBrowserRouter } from 'react-router-dom'
+import { NDKContextType } from 'contexts/NDKContext'
import { Layout } from 'layout'
-import { SearchPage } from 'pages/search'
+import { SearchPage } from '../pages/search'
import { AboutPage } from '../pages/about'
import { BlogsPage } from '../pages/blogs'
import { GamesPage } from '../pages/games'
@@ -10,12 +11,14 @@ import { ModsPage } from '../pages/mods'
import { ProfilePage } from '../pages/profile'
import { SettingsPage } from '../pages/settings'
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 { 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 { writeRouteAction } from '../pages/write/action'
+import { BlogPage } from 'pages/blog'
export const appRoutes = {
index: '/',
@@ -25,7 +28,8 @@ export const appRoutes = {
mods: '/mods',
mod: '/mod/:naddr',
about: '/about',
- blog: '/blog',
+ blogs: '/blogs',
+ blog: '/blog/:naddr',
submitMod: '/submit-mod',
editMod: '/edit-mod/:naddr',
write: '/write',
@@ -48,94 +52,103 @@ export const getModPageRoute = (eventId: string) =>
export const getModsEditPageRoute = (eventId: string) =>
appRoutes.editMod.replace(':naddr', eventId)
+export const getBlogPageRoute = (eventId: string) =>
+ appRoutes.blog.replace(':naddr', eventId)
+
export const getProfilePageRoute = (nprofile: string) =>
appRoutes.profile.replace(':nprofile', nprofile)
-export const router = createBrowserRouter([
- {
- element: ,
- children: [
- {
- path: appRoutes.index,
- element:
- },
- {
- path: appRoutes.games,
- element:
- },
- {
- path: appRoutes.game,
- element:
- },
- {
- path: appRoutes.mods,
- element:
- },
- {
- path: appRoutes.mod,
- element:
- },
- {
- path: appRoutes.about,
- element:
- },
- {
- path: appRoutes.blog,
- element:
- },
- {
- path: appRoutes.submitMod,
- element:
- },
- {
- path: appRoutes.editMod,
- element:
- },
- {
- path: appRoutes.write,
- element:
- },
- {
- path: appRoutes.search,
- element:
- },
- {
- path: appRoutes.settingsProfile,
- element:
- },
- {
- path: appRoutes.settingsRelays,
- element:
- },
- {
- path: appRoutes.settingsPreferences,
- element:
- },
- {
- path: appRoutes.settingsAdmin,
- element:
- },
- {
- path: appRoutes.profile,
- element:
- },
- {
- element: ,
- children: [
- {
- path: appRoutes.feed,
- element:
- },
- {
- path: appRoutes.notifications,
- element:
- }
- ]
- },
- {
- path: '*',
- element:
- }
- ]
- }
-])
+export const routerWithNdkContext = (context: NDKContextType) =>
+ createBrowserRouter([
+ {
+ element: ,
+ children: [
+ {
+ path: appRoutes.index,
+ element:
+ },
+ {
+ path: appRoutes.games,
+ element:
+ },
+ {
+ path: appRoutes.game,
+ element:
+ },
+ {
+ path: appRoutes.mods,
+ element:
+ },
+ {
+ path: appRoutes.mod,
+ element:
+ },
+ {
+ path: appRoutes.about,
+ element:
+ },
+ {
+ path: appRoutes.blogs,
+ element:
+ },
+ {
+ path: appRoutes.blog,
+ element:
+ },
+ {
+ path: appRoutes.submitMod,
+ element:
+ },
+ {
+ path: appRoutes.editMod,
+ element:
+ },
+ {
+ path: appRoutes.write,
+ element: ,
+ action: writeRouteAction(context)
+ },
+ {
+ path: appRoutes.search,
+ element:
+ },
+ {
+ path: appRoutes.settingsProfile,
+ element:
+ },
+ {
+ path: appRoutes.settingsRelays,
+ element:
+ },
+ {
+ path: appRoutes.settingsPreferences,
+ element:
+ },
+ {
+ path: appRoutes.settingsAdmin,
+ element:
+ },
+ {
+ path: appRoutes.profile,
+ element:
+ },
+ {
+ element: ,
+ children: [
+ {
+ path: appRoutes.feed,
+ element:
+ },
+ {
+ path: appRoutes.notifications,
+ element:
+ }
+ ]
+ },
+ {
+ path: '*',
+ element:
+ }
+ ]
+ }
+ ])
diff --git a/src/types/blog.ts b/src/types/blog.ts
new file mode 100644
index 0000000..7a7c956
--- /dev/null
+++ b/src/types/blog.ts
@@ -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 {}
diff --git a/src/types/index.ts b/src/types/index.ts
index c589a79..8fe37df 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -3,3 +3,4 @@ export * from './modsFilter'
export * from './nostr'
export * from './user'
export * from './zap'
+export * from './blog'
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index 6c904e0..4658496 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -146,3 +146,13 @@ export const scrollIntoView = (el: HTMLElement | null) => {
}, 100)
}
}
+
+export const parseFormData = (formData: FormData) => {
+ const result: Partial = {}
+
+ formData.forEach(
+ (value, key) => ((result as Record)[key] = value as string)
+ )
+
+ return result
+}