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