From d4148ed01de94c56a3d59c94dc12a35d18412373 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 18:14:45 +0100 Subject: [PATCH 01/19] feat: publishing blog, ndx in router, introduce actions --- package-lock.json | 28 ++++- package.json | 2 + src/App.tsx | 7 +- src/components/Inputs.tsx | 48 +++++++++ src/contexts/NDKContext.tsx | 2 +- src/hooks/useNDKContext.ts | 4 +- src/pages/blog.tsx | 1 + src/pages/write.tsx | 75 ------------- src/pages/write/action.tsx | 162 ++++++++++++++++++++++++++++ src/pages/write/index.tsx | 105 +++++++++++++++++++ src/routes/index.tsx | 203 +++++++++++++++++++----------------- src/types/blog.ts | 23 ++++ src/types/index.ts | 1 + src/utils/utils.ts | 10 ++ 14 files changed, 494 insertions(+), 177 deletions(-) create mode 100644 src/pages/blog.tsx delete mode 100644 src/pages/write.tsx create mode 100644 src/pages/write/action.tsx create mode 100644 src/pages/write/index.tsx create mode 100644 src/types/blog.ts 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 +} From c2413e1bd855daf5f702fab0f837e9791eb11790 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 20:14:06 +0100 Subject: [PATCH 02/19] fix: blog kind --- src/pages/write/action.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx index 4f21b7f..3dfb1a5 100644 --- a/src/pages/write/action.tsx +++ b/src/pages/write/action.tsx @@ -62,7 +62,7 @@ export const writeRouteAction = const uuid = uuidv4() const currentTimeStamp = now() - const aTag = `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` + const aTag = `${kinds.LongFormArticle}:${hexPubkey}:${uuid}` const tTags = formSubmit .tags!.toLowerCase() .split(',') From 3717c3bfb9a00b75a512e3c11bca51d57257bffa Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 20:14:29 +0100 Subject: [PATCH 03/19] feat: fetching blog data --- src/layout/header.tsx | 2 +- src/pages/blog.tsx | 1 - src/pages/blog/index.tsx | 227 +++++++++++++++++++++++++++++++++++++++ src/pages/blog/loader.ts | 70 ++++++++++++ src/routes/index.tsx | 8 +- src/types/blog.ts | 14 ++- src/utils/nostr.ts | 16 +++ 7 files changed, 332 insertions(+), 6 deletions(-) delete mode 100644 src/pages/blog.tsx create mode 100644 src/pages/blog/index.tsx create mode 100644 src/pages/blog/loader.ts diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 011b7cf..18d034f 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -212,7 +212,7 @@ export const Header = () => { About Blog diff --git a/src/pages/blog.tsx b/src/pages/blog.tsx deleted file mode 100644 index 47c67f8..0000000 --- a/src/pages/blog.tsx +++ /dev/null @@ -1 +0,0 @@ -export const BlogPage = () => <>WIP diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx new file mode 100644 index 0000000..971c453 --- /dev/null +++ b/src/pages/blog/index.tsx @@ -0,0 +1,227 @@ +import { LoadingSpinner } from 'components/LoadingSpinner' +import { ProfileSection } from 'components/ProfileSection' +import { useLoaderData } from 'react-router-dom' +import { BlogDetails } from 'types' + +export const BlogPage = () => { + const data = useLoaderData() as Partial + + if (!data) return + + return ( +
+
+
+
+
+
+ {/* */} +
+
+
+
+

Heading

+
+
+
+
+

NSFW

+
+ Tag 1 + Tag 2 + Tag 3 +
+
+
+ {/*
+
+ +
+
+ +
+

420

+
+
+
+
+ +
+

69k

+
+
+
+
+
+
+ +
+

4.2k

+
+
+
+
+
+
+ +
+

69

+
+
+
+
+
+
+
+
+
+ + +

01/11/2024

+
+
+ + +

24/11/2024

+
+ + +

degmods.com

+
+
+
+
*/} + {/* */} + {/*
+
+

Comments

+
+ +
+
+
+ +
+

Yo this article was insane to read!

+
+
+
+
+ + + +

52

+
+
+
+
+
+ + + +

4

+
+
+
+
+
+ + + +

6

+
+
+
+
+
+ + + +

500K

+
+
+
+
+
+ + + +

12

+

Replies

+
+
+

Reply

+
+
+
+
+
+
+
+
+
*/} +
+
+ {!!data.author && } +
+
+ + + ) +} diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts new file mode 100644 index 0000000..c241b7e --- /dev/null +++ b/src/pages/blog/loader.ts @@ -0,0 +1,70 @@ +import { filterForEventsTaggingId, NDKEvent } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { LoaderFunctionArgs, redirect } from 'react-router-dom' +import { toast } from 'react-toastify' +import { appRoutes } from 'routes' +import { BlogDetails } from 'types' +import { + getFirstTagValue, + getFirstTagValueAsInt, + getTagValue, + log, + LogType +} from 'utils' + +export const blogRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return redirect(appRoutes.blogs) + } + + try { + const filter = filterForEventsTaggingId(naddr) + if (!filter) { + log(true, LogType.Error, 'Unable to create filter from blog naddr.') + return redirect(appRoutes.blogs) + } + + const event = await ndkContext.fetchEvent(filter) + console.log(event) + if (event) { + const blogDetails = extractBlogDetails(event) + + console.log(blogDetails) + return blogDetails + } + + return null + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + toast.error('An error occurred in fetching mod details from relays') + return redirect(appRoutes.blogs) + } + } + +function extractBlogDetails(event: NDKEvent): Partial { + return { + title: getFirstTagValue(event, 'title'), + content: event.content, + summary: getFirstTagValue(event, 'summary'), + image: getFirstTagValue(event, 'image'), + nsfw: getFirstTagValue(event, 'nsfw') === 'true', + + id: event.id, + author: event.pubkey, + published_at: getFirstTagValueAsInt(event, 'published_at'), + edited_at: event.created_at, + rTag: getFirstTagValue(event, 'r') || 'N/A', + dTag: getFirstTagValue(event, 'd'), + aTag: getFirstTagValue(event, 'a'), + tTags: getTagValue(event, 't') || [] + } +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bc14e69..e875d86 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,7 +3,6 @@ import { NDKContextType } from 'contexts/NDKContext' import { Layout } from 'layout' import { SearchPage } from '../pages/search' import { AboutPage } from '../pages/about' -import { BlogsPage } from '../pages/blogs' import { GamesPage } from '../pages/games' import { HomePage } from '../pages/home' import { ModPage } from '../pages/mod' @@ -18,7 +17,9 @@ import { FeedPage } from '../pages/feed' import { NotificationsPage } from '../pages/notifications' import { WritePage } from '../pages/write' import { writeRouteAction } from '../pages/write/action' +import { BlogsPage } from 'pages/blogs' import { BlogPage } from 'pages/blog' +import { blogRouteLoader } from 'pages/blog/loader' export const appRoutes = { index: '/', @@ -29,7 +30,7 @@ export const appRoutes = { mod: '/mod/:naddr', about: '/about', blogs: '/blogs', - blog: '/blog/:naddr', + blog: '/blogs/:naddr', submitMod: '/submit-mod', editMod: '/edit-mod/:naddr', write: '/write', @@ -93,7 +94,8 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.blog, - element: + element: , + loader: blogRouteLoader(context) }, { path: appRoutes.submitMod, diff --git a/src/types/blog.ts b/src/types/blog.ts index 7a7c956..bedb751 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -10,12 +10,24 @@ export interface BlogDetails { author: string published_at: number edited_at: number + rTag: string + dTag: string + aTag: string + tTags: string[] } export interface BlogFormSubmit extends Omit< BlogDetails, - 'nsfw' | 'id' | 'author' | 'published_at' | 'edited_at' + | 'nsfw' + | 'id' + | 'author' + | 'published_at' + | 'edited_at' + | 'rTag' + | 'dTag' + | 'aTag' + | 'tTag' > { nsfw: string } diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 130d023..b1fd0e1 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -65,6 +65,22 @@ export const getTagValue = ( return null } +export const getFirstTagValue = ( + event: Event | NDKEvent, + tagIdentifier: string +) => { + const tags = getTagValue(event, tagIdentifier) + return tags && tags.length ? tags[0] : undefined +} + +export const getFirstTagValueAsInt = ( + event: Event | NDKEvent, + tagIdentifier: string +) => { + const value = getFirstTagValue(event, tagIdentifier) + return value ? parseInt(value, 10) : undefined +} + /** * @param hexKey hex private or public key * @returns whether or not is key valid From 847aab29d7c0d2c3810fcd6c902f8cce75ea19b3 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 13:57:39 +0100 Subject: [PATCH 04/19] feat: add admin blog page, content parse and markdown --- .env.example | 5 +- package-lock.json | 372 +++++++++++------- package.json | 10 +- src/components/BlogCard.tsx | 46 +-- src/components/Inputs.tsx | 29 +- src/components/Internal/Interactions.tsx | 42 ++ src/components/Internal/PublishDetails.tsx | 86 ++++ .../Internal/Reactions.tsx} | 12 +- .../index.tsx => components/Internal/Zap.tsx} | 18 +- src/components/ModCard.tsx | 2 +- .../internal => components}/comment/index.tsx | 21 +- src/hooks/useComments.ts | 20 +- src/pages/blog/index.tsx | 249 ++++-------- src/pages/blog/loader.ts | 48 +-- src/pages/blogs.tsx | 144 ------- src/pages/blogs/index.tsx | 205 ++++++++++ src/pages/blogs/loader.ts | 37 ++ src/pages/home.tsx | 8 +- src/pages/mod/index.tsx | 138 +------ src/pages/write/action.tsx | 10 +- src/routes/index.tsx | 8 +- src/types/blog.ts | 28 +- src/types/nostr.ts | 6 + src/utils/blog.ts | 38 ++ src/vite-env.d.ts | 1 + 25 files changed, 865 insertions(+), 718 deletions(-) create mode 100644 src/components/Internal/Interactions.tsx create mode 100644 src/components/Internal/PublishDetails.tsx rename src/{pages/mod/internal/reactions/index.tsx => components/Internal/Reactions.tsx} (92%) rename src/{pages/mod/internal/zap/index.tsx => components/Internal/Zap.tsx} (88%) rename src/{pages/mod/internal => components}/comment/index.tsx (97%) delete mode 100644 src/pages/blogs.tsx create mode 100644 src/pages/blogs/index.tsx create mode 100644 src/pages/blogs/loader.ts create mode 100644 src/utils/blog.ts diff --git a/.env.example b/.env.example index e6b55e6..83ddd81 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,7 @@ VITE_REPORTING_NPUB= VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png # if there's no image, or if the image breaks somewhere down the line, then it should default to this image -VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png \ No newline at end of file +VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png + +# A comma separated list of npubs, this list is used to fetch just the posts from the admin +VITE_BLOG_NPUBS= diff --git a/package-lock.json b/package-lock.json index f29ff29..19b9ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,11 @@ "@nostr-dev-kit/ndk": "2.10.0", "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@reduxjs/toolkit": "2.2.6", - "@tiptap/core": "2.6.6", - "@tiptap/extension-link": "2.6.6", - "@tiptap/react": "2.6.6", - "@tiptap/starter-kit": "2.6.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", "axios": "1.7.3", "bech32": "2.0.0", @@ -26,6 +27,7 @@ "file-saver": "2.0.5", "fslightbox-react": "1.7.6", "lodash": "4.17.21", + "marked": "^14.1.3", "nostr-login": "1.5.2", "nostr-tools": "2.7.1", "papaparse": "5.4.1", @@ -1160,6 +1162,7 @@ "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -1189,9 +1192,10 @@ } }, "node_modules/@remirror/core-constants": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz", - "integrity": "sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" }, "node_modules/@remix-run/router": { "version": "1.17.1", @@ -1487,45 +1491,49 @@ } }, "node_modules/@tiptap/core": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz", - "integrity": "sha512-VO5qTsjt6rwworkuo0s5AqYMfDA0ZwiTiH6FHKFSu2G/6sS7HKcc/LjPq+5Legzps4QYdBDl3W28wGsGuS1GdQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.9.1.tgz", + "integrity": "sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^2.6.6" + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.6.6.tgz", - "integrity": "sha512-hAdsNlMfzzxld154hJqPqtWqO5i4/7HoDfuxmyqBxdMJ+e2UMaIGBGwoLRXG0V9UoRwJusjqlpyD7pIorxNlgA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.9.1.tgz", + "integrity": "sha512-Y0jZxc/pdkvcsftmEZFyG+73um8xrx6/DMfgUcNg3JAM63CISedNcr+OEI11L0oFk1KFT7/aQ9996GM6Kubdqg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-bold": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.6.6.tgz", - "integrity": "sha512-CD6gBhdQtCoqYSmx8oAV8gvKtVOGZSyyvuNYo7by9eZ56DqLYnd7kbUj0RH7o9Ymf/iJTOUJ6XcvrsWwo4lubg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.9.1.tgz", + "integrity": "sha512-e2P1zGpnnt4+TyxTC5pX/lPxPasZcuHCYXY0iwQ3bf8qRQQEjDfj3X7EI+cXqILtnhOiviEOcYmeu5op2WhQDg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.6.6.tgz", - "integrity": "sha512-IkfmlZq67aaegym5sBddBc/xXWCArxn5WJEl1oxKEayjQhybKSaqI7tk0lOx/x7fa5Ml1WlGpCFh+KKXbQTG0g==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.9.1.tgz", + "integrity": "sha512-DWUF6NG08/bZDWw0jCeotSTvpkyqZTi4meJPomG9Wzs/Ol7mEwlNCsCViD999g0+IjyXFatBk4DfUq1YDDu++Q==", + "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" }, @@ -1534,76 +1542,82 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.6.6.tgz", - "integrity": "sha512-WEKxbVSYuvmX2wkHWP8HXk5nzA7stYwtdaubwWH/R17kGI3IGScJuMQ9sEN82uzJU8bfgL9yCbH2bY8Fj/Q4Ow==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.9.1.tgz", + "integrity": "sha512-0hizL/0j9PragJObjAWUVSuGhN1jKjCFnhLQVRxtx4HutcvS/lhoWMvFg6ZF8xqWgIa06n6A7MaknQkqhTdhKA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-code": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.6.6.tgz", - "integrity": "sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.9.1.tgz", + "integrity": "sha512-WQqcVGe7i/E+yO3wz5XQteU1ETNZ00euUEl4ylVVmH2NM4Dh0KDjEhbhHlCM0iCfLUo7jhjC7dmS+hMdPUb+Tg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.6.6.tgz", - "integrity": "sha512-1YLp/zHMHSkE2xzht8nPR6T4sQJJ3ket798czxWuQEbetFv/l0U/mpiPpYSLObj6oTAoqYZ0kWXZj5eQSpPB8Q==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.9.1.tgz", + "integrity": "sha512-A/50wPWDqEUUUPhrwRKILP5gXMO5UlQ0F6uBRGYB9CEVOREam9yIgvONOnZVJtszHqOayjIVMXbH/JMBeq11/g==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-document": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.6.6.tgz", - "integrity": "sha512-6qlH5VWzLHHRVeeciRC6C4ZHpMsAGPNG16EF53z0GeMSaaFD/zU3B239QlmqXmLsAl8bpf8Bn93N0t2ABUvScw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.9.1.tgz", + "integrity": "sha512-1a+HCoDPnBttjqExfYLwfABq8MYdiowhy/wp8eCxVb6KGFEENO53KapstISvPzqH7eOi+qRjBB1KtVYb/ZXicg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.6.6.tgz", - "integrity": "sha512-O6CeKriA9uyHsg7Ui4z5ZjEWXQxrIL+1zDekffW0wenGC3G4LUsCzAiFS4LSrR9a3u7tnwqGApW10rdkmCGF4w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.9.1.tgz", + "integrity": "sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.6.6.tgz", - "integrity": "sha512-lPkESOfAUxgmXRiNqUU23WSyja5FUfSWjsW4hqe+BKNjsUt1OuFMEtYJtNc+MCGhhtPfFvM3Jg6g9jd6g5XsLQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.9.1.tgz", + "integrity": "sha512-MxZ7acNNsoNaKpetxfwi3Z11Bgrh0T2EJlCV77v9N1vWK38+st3H1WJanmLbPNtc2ocvhHJrz+DjDz3CWxQ9rQ==", + "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" }, @@ -1612,89 +1626,109 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.6.6.tgz", - "integrity": "sha512-O2lQ2t0X0Vsbn3yLWxFFHrXY6C2N9Y6ZF/M7LWzpcDTUZeWuhoNkFE/1yOM0h6ZX1DO2A9hNIrKpi5Ny8yx+QA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.9.1.tgz", + "integrity": "sha512-jsRBmX01vr+5H02GljiHMo0n5H1vzoMLmFarxe0Yq2d2l9G/WV2VWX2XnGliqZAYWd1bI0phs7uLQIN3mxGQTw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.6.6.tgz", - "integrity": "sha512-bsUuyYBrMDEiudx1dOQSr9MzKv13m0xHWrOK+DYxuIDYJb5g+c9un5cK7Js+et/HEYYSPOoH/iTW6h+4I5YeUg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.9.1.tgz", + "integrity": "sha512-fCuaOD/b7nDjm47PZ58oanq7y4ccS2wjPh42Qm0B0yipu/1fmC8eS1SmaXmk28F89BLtuL6uOCtR1spe+lZtlQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-heading": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.6.6.tgz", - "integrity": "sha512-bgx9vptVFi5yFkIw1OI53J7+xJ71Or3SOe/Q8eSpZv53DlaKpL/TzKw8Z54t1PrI2rJ6H9vrLtkvixJvBZH1Ug==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.9.1.tgz", + "integrity": "sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-history": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.6.6.tgz", - "integrity": "sha512-tPTzAmPGqMX5Bd5H8lzRpmsaMvB9DvI5Dy2za/VQuFtxgXmDiFVgHRkRXIuluSkPTuANu84XBOQ0cBijqY8x4w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.9.1.tgz", + "integrity": "sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.6.6.tgz", - "integrity": "sha512-cFEfv7euDpuLSe8exY8buwxkreKBAZY9Hn3EetKhPcLQo+ut5Y24chZTxFyf9b+Y0wz3UhOhLTZSz7fTobLqBA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.9.1.tgz", + "integrity": "sha512-ydUhABeaBI1CoJp+/BBqPhXINfesp1qMNL/jiDcMsB66fsD4nOyphpAJT7FaRFZFtQVF06+nttBtFZVkITQVqg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.9.1.tgz", + "integrity": "sha512-aGqJnsuS8oagIhsx7wetm8jw4NEDsOV0OSx4FQ4VPlUqWlnzK0N+erFKKJmXTdAxL8PGzoPSlITFH63MV3eV3Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-italic": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.6.6.tgz", - "integrity": "sha512-t7ZPsXqa8nJZZ/6D0rQyZ/KsvzLaSihC6hBTjUQ77CeDGV9PhDWjIcBW4OrvwraJDBd12ETBeQ2CkULJOgH+lQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.9.1.tgz", + "integrity": "sha512-VkNA6Vz96+/+7uBlsgM7bDXXx4b62T1fDam/3UKifA72aD/fZckeWrbT7KrtdUbzuIniJSbA0lpTs5FY29+86Q==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-link": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.6.6.tgz", - "integrity": "sha512-NJSR5Yf/dI3do0+Mr6e6nkbxRQcqbL7NOPxo5Xw8VaKs2Oe8PX+c7hyqN3GZgn6uEbZdbVi1xjAniUokouwpFg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.9.1.tgz", + "integrity": "sha512-yG+e3e8cCCN9dZjX4ttEe3e2xhh58ryi3REJV4MdiEkOT9QF75Bl5pUbMIS4tQ8HkOr04QBFMHKM12kbSxg1BA==", + "license": "MIT", "dependencies": { "linkifyjs": "^4.1.0" }, @@ -1703,78 +1737,97 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.6.6.tgz", - "integrity": "sha512-k+oEzZu2cgVKqPqOP1HzASOKLpTEV9m7mRVPAbuaaX8mSyvIgD6f+JUx9PvgYv//D918wk98LMoRBFX53tDJ4w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.9.1.tgz", + "integrity": "sha512-6O4NtYNR5N2Txi4AC0/4xMRJq9xd4+7ShxCZCDVL0WDVX37IhaqMO7LGQtA6MVlYyNaX4W1swfdJaqrJJ5HIUw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.6.6.tgz", - "integrity": "sha512-AJwyfLXIi7iUGnK5twJbwdVVpQyh7fU6OK75h1AwDztzsOcoPcxtffDlZvUOd4ZtwuyhkzYqVkeI0f+abTWZTw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.9.1.tgz", + "integrity": "sha512-6J9jtv1XP8dW7/JNSH/K4yiOABc92tBJtgCsgP8Ep4+fjfjdj4HbjS1oSPWpgItucF2Fp/VF8qg55HXhjxHjTw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.6.6.tgz", - "integrity": "sha512-fD/onCr16UQWx+/xEmuFC2MccZZ7J5u4YaENh8LMnAnBXf78iwU7CAcmuc9rfAEO3qiLoYGXgLKiHlh2ZfD4wA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.9.1.tgz", + "integrity": "sha512-JOmT0xd4gd3lIhLwrsjw8lV+ZFROKZdIxLi0Ia05XSu4RLrrvWj0zdKMSB+V87xOWfSB3Epo95zAvnPox5Q16A==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-strike": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.6.6.tgz", - "integrity": "sha512-Ze8KhGk+wzSJSJRl5fbhTI6AvPu2LmcHYeO3pMEH8u4gV5WTXfmKJVStEIAzkoqvwEQVWzXvy8nDgsFQHiojPg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.9.1.tgz", + "integrity": "sha512-V5aEXdML+YojlPhastcu7w4biDPwmzy/fWq0T2qjfu5Te/THcqDmGYVBKESBm5x6nBy5OLkanw2O+KHu2quDdg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/extension-text": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.6.6.tgz", - "integrity": "sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.9.1.tgz", + "integrity": "sha512-3wo9uCrkLVLQFgbw2eFU37QAa1jq1/7oExa+FF/DVxdtHRS9E2rnUZ8s2hat/IWzvPUHXMwo3Zg2XfhoamQpCA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6" + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.9.1.tgz", + "integrity": "sha512-LAxc0SeeiPiAVBwksczeA7BJSZb6WtVpYhy5Esvy9K0mK5kttB4KxtnXWeQzMIJZQbza65yftGKfQlexf/Y7yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" } }, "node_modules/@tiptap/pm": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.6.6.tgz", - "integrity": "sha512-56FGLPn3fwwUlIbLs+BO21bYfyqP9fKyZQbQyY0zWwA/AG2kOwoXaRn7FOVbjP6CylyWpFJnpRRmgn694QKHEg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.9.1.tgz", + "integrity": "sha512-mvV86fr7kEuDYEApQ2uMPCKL2uagUE0BsXiyyz3KOkY1zifyVm1fzdkscb24Qy1GmLzWAIIihA+3UHNRgYdOlQ==", + "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.5.2", + "prosemirror-commands": "^1.6.0", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", @@ -1782,14 +1835,14 @@ "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.0", "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.22.2", + "prosemirror-model": "^1.22.3", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.4.0", - "prosemirror-trailing-node": "^2.0.9", - "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.9" + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.0", + "prosemirror-view": "^1.34.3" }, "funding": { "type": "github", @@ -1797,13 +1850,15 @@ } }, "node_modules/@tiptap/react": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.6.6.tgz", - "integrity": "sha512-AUmdb/J1O/vCO2b8LL68ctcZr9a3931BwX4fUUZ1kCrCA5lTj2xz0rjeAtpxEdzLnR+Z7q96vB7vf7bPYOUAew==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.9.1.tgz", + "integrity": "sha512-LQJ34ZPfXtJF36SZdcn4Fiwsl2WxZ9YRJI87OLnsjJ45O+gV/PfBzz/4ap+LF8LOS0AbbGhTTjBOelPoNm+aYA==", + "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.6.6", - "@tiptap/extension-floating-menu": "^2.6.6", + "@tiptap/extension-bubble-menu": "^2.9.1", + "@tiptap/extension-floating-menu": "^2.9.1", "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", "use-sync-external-store": "^1.2.2" }, "funding": { @@ -1811,8 +1866,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/pm": "^2.6.6", + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" } @@ -1823,30 +1878,32 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, "node_modules/@tiptap/starter-kit": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.6.6.tgz", - "integrity": "sha512-zb9xIg3WjG9AsJoyWrfqx5SL9WH7/HTdkB79jFpWtOF/Kaigo7fHFmhs2FsXtJMJlcdMTO2xeRuCYHt5ozXlhg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.9.1.tgz", + "integrity": "sha512-nsw6UF/7wDpPfHRhtGOwkj1ipIEiWZS1VGw+c14K61vM1CNj0uQ4jogbHwHZqN1dlL5Hh+FCqUHDPxG6ECbijg==", + "license": "MIT", "dependencies": { - "@tiptap/core": "^2.6.6", - "@tiptap/extension-blockquote": "^2.6.6", - "@tiptap/extension-bold": "^2.6.6", - "@tiptap/extension-bullet-list": "^2.6.6", - "@tiptap/extension-code": "^2.6.6", - "@tiptap/extension-code-block": "^2.6.6", - "@tiptap/extension-document": "^2.6.6", - "@tiptap/extension-dropcursor": "^2.6.6", - "@tiptap/extension-gapcursor": "^2.6.6", - "@tiptap/extension-hard-break": "^2.6.6", - "@tiptap/extension-heading": "^2.6.6", - "@tiptap/extension-history": "^2.6.6", - "@tiptap/extension-horizontal-rule": "^2.6.6", - "@tiptap/extension-italic": "^2.6.6", - "@tiptap/extension-list-item": "^2.6.6", - "@tiptap/extension-ordered-list": "^2.6.6", - "@tiptap/extension-paragraph": "^2.6.6", - "@tiptap/extension-strike": "^2.6.6", - "@tiptap/extension-text": "^2.6.6", - "@tiptap/pm": "^2.6.6" + "@tiptap/core": "^2.9.1", + "@tiptap/extension-blockquote": "^2.9.1", + "@tiptap/extension-bold": "^2.9.1", + "@tiptap/extension-bullet-list": "^2.9.1", + "@tiptap/extension-code": "^2.9.1", + "@tiptap/extension-code-block": "^2.9.1", + "@tiptap/extension-document": "^2.9.1", + "@tiptap/extension-dropcursor": "^2.9.1", + "@tiptap/extension-gapcursor": "^2.9.1", + "@tiptap/extension-hard-break": "^2.9.1", + "@tiptap/extension-heading": "^2.9.1", + "@tiptap/extension-history": "^2.9.1", + "@tiptap/extension-horizontal-rule": "^2.9.1", + "@tiptap/extension-italic": "^2.9.1", + "@tiptap/extension-list-item": "^2.9.1", + "@tiptap/extension-ordered-list": "^2.9.1", + "@tiptap/extension-paragraph": "^2.9.1", + "@tiptap/extension-strike": "^2.9.1", + "@tiptap/extension-text": "^2.9.1", + "@tiptap/extension-text-style": "^2.9.1", + "@tiptap/pm": "^2.9.1" }, "funding": { "type": "github", @@ -3208,8 +3265,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -3862,6 +3918,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz", + "integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -4519,11 +4587,12 @@ } }, "node_modules/prosemirror-trailing-node": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-2.0.9.tgz", - "integrity": "sha512-YvyIn3/UaLFlFKrlJB6cObvUhmwFNZVhy1Q8OpW/avoTbD/Y7H5EcjK4AZFKhmuS6/N6WkGgt7gWtBWDnmFvHg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", "dependencies": { - "@remirror/core-constants": "^2.0.2", + "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" }, "peerDependencies": { @@ -4536,6 +4605,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -4552,9 +4622,10 @@ } }, "node_modules/prosemirror-view": { - "version": "1.34.1", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.1.tgz", - "integrity": "sha512-KS2xmqrAM09h3SLu1S2pNO/ZoIP38qkTJ6KFd7+BeSfmX/ek0n5yOfGuiTZjFNTC8GOsEIUa1tHxt+2FMu3yWQ==", + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.35.0.tgz", + "integrity": "sha512-Umtbh22fmUlpZpRTiOVXA0PpdRZeYEeXQsLp51VfnMhjkJrqJ0n8APinIZrRAD5Jr3UxH8FnOaUqRylSuMsqHA==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5043,6 +5114,7 @@ "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", "dependencies": { "@popperjs/core": "^2.9.0" } diff --git a/package.json b/package.json index fa455fa..b356c8a 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "@nostr-dev-kit/ndk": "2.10.0", "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@reduxjs/toolkit": "2.2.6", - "@tiptap/core": "2.6.6", - "@tiptap/extension-link": "2.6.6", - "@tiptap/react": "2.6.6", - "@tiptap/starter-kit": "2.6.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", "axios": "1.7.3", "bech32": "2.0.0", @@ -28,6 +29,7 @@ "file-saver": "2.0.5", "fslightbox-react": "1.7.6", "lodash": "4.17.21", + "marked": "^14.1.3", "nostr-login": "1.5.2", "nostr-tools": "2.7.1", "papaparse": "5.4.1", diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index e2065b7..e2d52a9 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -1,37 +1,33 @@ +import { Link } from 'react-router-dom' +import { BlogCardDetails } from 'types' +import { getBlogPageRoute } from 'routes' import '../styles/cardBlogs.css' +import placeholder from '../assets/img/DEGMods Placeholder Img.png' -type BlogCardProps = { - backgroundLink: string -} +type BlogCardProps = Partial + +export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => { + if (!naddr) return null -export const BlogCard = ({ backgroundLink }: BlogCardProps) => { return ( - + + ) } diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index b21e544..036e538 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -1,4 +1,5 @@ import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' import { Editor, EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import React, { useEffect } from 'react' @@ -127,7 +128,15 @@ type RichTextEditorProps = { const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => { const editor = useEditor({ - extensions: [StarterKit, Link], + extensions: [ + StarterKit, + Link, + Image.configure({ + HTMLAttributes: { + class: 'IBMSMSMBSSPostImg' + } + }) + ], onUpdate: ({ editor }) => { // Update the state when the editor content changes updateContent(editor.getHTML()) @@ -181,6 +190,17 @@ const MenuBar = ({ editor }: MenuBarProps) => { 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', @@ -211,7 +231,7 @@ const MenuBar = ({ editor }: MenuBarProps) => { { label: 'Paragraph', isActive: editor.isActive('paragraph'), - onClick: () => editor.chain().focus().toggleStrike().run() + onClick: () => editor.chain().focus().setParagraph().run() }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...[1, 2, 3, 4, 5, 6].map((level: any) => ({ @@ -244,6 +264,11 @@ const MenuBar = ({ editor }: MenuBarProps) => { 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() diff --git a/src/components/Internal/Interactions.tsx b/src/components/Internal/Interactions.tsx new file mode 100644 index 0000000..534c910 --- /dev/null +++ b/src/components/Internal/Interactions.tsx @@ -0,0 +1,42 @@ +import { Addressable } from 'types' +import { abbreviateNumber } from 'utils' +import { Zap } from './Zap' +import { Reactions } from './Reactions' + +type InteractionsProps = { + addressable: Addressable + commentCount: number +} + +export const Interactions = ({ + addressable, + commentCount +}: InteractionsProps) => { + return ( + + ) +} diff --git a/src/components/Internal/PublishDetails.tsx b/src/components/Internal/PublishDetails.tsx new file mode 100644 index 0000000..88cef78 --- /dev/null +++ b/src/components/Internal/PublishDetails.tsx @@ -0,0 +1,86 @@ +import { formatDate } from 'date-fns' + +type PublishDetailsProps = { + published_at: number + edited_at: number + site: string +} + +export const PublishDetails = ({ + published_at, + edited_at, + site +}: PublishDetailsProps) => { + return ( +
+
+
+ + + +

+ {formatDate( + (published_at !== -1 ? published_at : edited_at) * 1000, + 'dd/MM/yyyy hh:mm:ss aa' + )} +

+
+
+ + + +

+ {formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')} +

+
+ + + + +

{site}

+
+
+
+ ) +} diff --git a/src/pages/mod/internal/reactions/index.tsx b/src/components/Internal/Reactions.tsx similarity index 92% rename from src/pages/mod/internal/reactions/index.tsx rename to src/components/Internal/Reactions.tsx index d50a787..4e69f13 100644 --- a/src/pages/mod/internal/reactions/index.tsx +++ b/src/components/Internal/Reactions.tsx @@ -1,11 +1,11 @@ import { useReactions } from 'hooks' -import { ModDetails } from 'types' +import { Addressable } from 'types' type ReactionsProps = { - modDetails: ModDetails + addressable: Addressable } -export const Reactions = ({ modDetails }: ReactionsProps) => { +export const Reactions = ({ addressable }: ReactionsProps) => { const { isDataLoaded, likesCount, @@ -14,9 +14,9 @@ export const Reactions = ({ modDetails }: ReactionsProps) => { hasReactedPositively, hasReactedNegatively } = useReactions({ - pubkey: modDetails.author, - eTag: modDetails.id, - aTag: modDetails.aTag + pubkey: addressable.author, + eTag: addressable.id, + aTag: addressable.aTag }) if (!isDataLoaded) return null diff --git a/src/pages/mod/internal/zap/index.tsx b/src/components/Internal/Zap.tsx similarity index 88% rename from src/pages/mod/internal/zap/index.tsx rename to src/components/Internal/Zap.tsx index 996c8d2..0c5cb7a 100644 --- a/src/pages/mod/internal/zap/index.tsx +++ b/src/components/Internal/Zap.tsx @@ -7,14 +7,14 @@ import { } from 'hooks' import { useState } from 'react' import { toast } from 'react-toastify' -import { ModDetails } from 'types' +import { Addressable } from 'types' import { abbreviateNumber } from 'utils' type ZapProps = { - modDetails: ModDetails + addressable: Addressable } -export const Zap = ({ modDetails }: ZapProps) => { +export const Zap = ({ addressable }: ZapProps) => { const [isOpen, setIsOpen] = useState(false) const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [hasZapped, setHasZapped] = useState(false) @@ -26,9 +26,9 @@ export const Zap = ({ modDetails }: ZapProps) => { useDidMount(() => { getTotalZapAmount( - modDetails.author, - modDetails.id, - modDetails.aTag, + addressable.author, + addressable.id, + addressable.aTag, userState.user?.pubkey as string ) .then((res) => { @@ -70,9 +70,9 @@ export const Zap = ({ modDetails }: ZapProps) => { {isOpen && ( setIsOpen(false)} diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index 6abd397..c54b300 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -12,7 +12,7 @@ import { useComments } from 'hooks/useComments' export const ModCard = React.memo((props: ModDetails) => { const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [commentCount, setCommentCount] = useState(0) - const { commentEvents } = useComments(props) + const { commentEvents } = useComments(props.author, props.aTag) const { likesCount, disLikesCount } = useReactions({ pubkey: props.author, eTag: props.id, diff --git a/src/pages/mod/internal/comment/index.tsx b/src/components/comment/index.tsx similarity index 97% rename from src/pages/mod/internal/comment/index.tsx rename to src/components/comment/index.tsx index f238524..8d7fd93 100644 --- a/src/pages/mod/internal/comment/index.tsx +++ b/src/components/comment/index.tsx @@ -21,9 +21,9 @@ import { Link } from 'react-router-dom' import { toast } from 'react-toastify' import { getProfilePageRoute } from 'routes' import { + Addressable, CommentEvent, CommentEventStatus, - ModDetails, UserProfile } from 'types/index.ts' import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils' @@ -44,13 +44,16 @@ type FilterOptions = { } type Props = { - modDetails: ModDetails + addressable: Addressable setCommentCount: Dispatch> } -export const Comments = ({ modDetails, setCommentCount }: Props) => { +export const Comments = ({ addressable, setCommentCount }: Props) => { const { ndk, publish } = useNDKContext() - const { commentEvents, setCommentEvents } = useComments(modDetails) + const { commentEvents, setCommentEvents } = useComments( + addressable.author, + addressable.aTag + ) const [filterOptions, setFilterOptions] = useState({ sort: SortByEnum.Latest, author: AuthorFilterEnum.All_Comments @@ -84,9 +87,9 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { kind: kinds.ShortTextNote, created_at: now(), tags: [ - ['e', modDetails.id], - ['a', modDetails.aTag], - ['p', modDetails.author] + ['e', addressable.id], + ['a', addressable.aTag], + ['p', addressable.author] ] } @@ -176,7 +179,7 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { let filteredComments = commentEvents if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { filteredComments = filteredComments.filter( - (comment) => comment.pubkey === modDetails.author + (comment) => comment.pubkey === addressable.author ) } @@ -187,7 +190,7 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { } return filteredComments - }, [commentEvents, filterOptions, modDetails.author]) + }, [commentEvents, filterOptions, addressable.author]) return (
diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index fb57eb8..8b107bc 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -7,22 +7,30 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { useEffect, useState } from 'react' -import { CommentEvent, ModDetails, UserRelaysType } from 'types' +import { CommentEvent, UserRelaysType } from 'types' import { log, LogType, timeout } from 'utils' import { useNDKContext } from './useNDKContext' -export const useComments = (mod: ModDetails) => { +export const useComments = ( + author: string | undefined, + aTag: string | undefined +) => { const { ndk } = useNDKContext() const [commentEvents, setCommentEvents] = useState([]) useEffect(() => { + if (!(author && aTag)) { + // Author and aTag are required + return + } + let subscription: NDKSubscription // Define the subscription variable here for cleanup const setupSubscription = async () => { // Find the mod author's relays. const authorReadRelays = await Promise.race([ - getRelayListForUser(mod.author, ndk), + getRelayListForUser(author, ndk), timeout(10 * 1000) // add a 10 sec timeout ]) .then((ndkRelayList) => { @@ -33,7 +41,7 @@ export const useComments = (mod: ModDetails) => { log( true, LogType.Error, - `An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`, + `An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`, err ) return [] as string[] @@ -41,7 +49,7 @@ export const useComments = (mod: ModDetails) => { const filter: NDKFilter = { kinds: [NDKKind.Text], - '#a': [mod.aTag] + '#a': [aTag] } const relayUrls = new Set() @@ -92,7 +100,7 @@ export const useComments = (mod: ModDetails) => { subscription.stop() } } - }, [mod.aTag, mod.author, ndk]) + }, [aTag, author, ndk]) return { commentEvents, diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 971c453..46becf8 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,21 +1,50 @@ +import { useState } from 'react' +import { useLoaderData } 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 { ProfileSection } from 'components/ProfileSection' -import { useLoaderData } from 'react-router-dom' -import { BlogDetails } from 'types' +import { Comments } from 'components/comment' +import { Addressable, BlogDetails } from 'types' +import placeholder from '../../assets/img/DEGMods Placeholder Img.png' +import { PublishDetails } from 'components/Internal/PublishDetails' +import { Interactions } from 'components/Internal/Interactions' export const BlogPage = () => { const data = useLoaderData() as Partial - - if (!data) return + const [commentCount, setCommentCount] = useState(0) + const html = marked.parse(data?.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 + }) return (
-
-
- {/*
+ {!data ? ( + + ) : ( +
+
+ {/*

Post for: Mod Name

*/} -
-
-
-
-

Heading

-
-
-
-
-

NSFW

+
+
+
+
+

+ {data.title} +

+
+
+ +
+
+ {data.nsfw && ( +
+

NSFW

+
+ )} + {data.tTags && + data.tTags.map((t) => ( + + {t} + + ))}
- Tag 1 - Tag 2 - Tag 3
-
- {/*
-
- -
-
- -
-

420

-
-
-
-
- -
-

69k

-
-
-
-
-
-
- -
-

4.2k

-
-
-
-
-
-
- -
-

69

-
-
-
-
-
-
-
-
-
- - -

01/11/2024

-
-
- - -

24/11/2024

-
- - -

degmods.com

-
-
-
-
*/} - {/*
+ + + {/* */} - {/*
-
-

Comments

-
- -
-
-
- -
-

Yo this article was insane to read!

-
-
-
-
- - - -

52

-
-
-
-
-
- - - -

4

-
-
-
-
-
- - - -

6

-
-
-
-
-
- - - -

500K

-
-
-
-
-
- - - -

12

-

Replies

-
-
-

Reply

-
-
-
-
-
-
-
+
+
-
*/} +
-
- {!!data.author && } + )} + {!!data?.author && }
diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index c241b7e..4cd0338 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -1,16 +1,10 @@ -import { filterForEventsTaggingId, NDKEvent } from '@nostr-dev-kit/ndk' +import { filterForEventsTaggingId } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes } from 'routes' -import { BlogDetails } from 'types' -import { - getFirstTagValue, - getFirstTagValueAsInt, - getTagValue, - log, - LogType -} from 'utils' +import { log, LogType } from 'utils' +import { extractBlogDetails } from 'utils/blog' export const blogRouteLoader = (ndkContext: NDKContextType) => @@ -23,21 +17,20 @@ export const blogRouteLoader = try { const filter = filterForEventsTaggingId(naddr) + if (!filter) { log(true, LogType.Error, 'Unable to create filter from blog naddr.') return redirect(appRoutes.blogs) } - const event = await ndkContext.fetchEvent(filter) - console.log(event) - if (event) { - const blogDetails = extractBlogDetails(event) - - console.log(blogDetails) - return blogDetails + if (!event) { + log(true, LogType.Error, 'Unable to fetch the blog event.') + return null } - return null + const blogDetails = extractBlogDetails(event) + + return blogDetails } catch (error) { log( true, @@ -45,26 +38,7 @@ export const blogRouteLoader = 'An error occurred in fetching blog details from relays', error ) - toast.error('An error occurred in fetching mod details from relays') + toast.error('An error occurred in fetching blog details from relays') return redirect(appRoutes.blogs) } } - -function extractBlogDetails(event: NDKEvent): Partial { - return { - title: getFirstTagValue(event, 'title'), - content: event.content, - summary: getFirstTagValue(event, 'summary'), - image: getFirstTagValue(event, 'image'), - nsfw: getFirstTagValue(event, 'nsfw') === 'true', - - id: event.id, - author: event.pubkey, - published_at: getFirstTagValueAsInt(event, 'published_at'), - edited_at: event.created_at, - rTag: getFirstTagValue(event, 'r') || 'N/A', - dTag: getFirstTagValue(event, 'd'), - aTag: getFirstTagValue(event, 'a'), - tTags: getTagValue(event, 't') || [] - } -} diff --git a/src/pages/blogs.tsx b/src/pages/blogs.tsx deleted file mode 100644 index 822aa61..0000000 --- a/src/pages/blogs.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { BlogCard } from '../components/BlogCard' -import '../styles/filters.css' -import '../styles/pagination.css' -import '../styles/search.css' -import '../styles/styles.css' -import placeholder from '../assets/img/DEGMods Placeholder Img.png' - -export const BlogsPage = () => { - return ( -
-
-
-
-
-
-

Blogs (WIP)

-
-
-
-
- - -
-
-
-
-
- - - -
-
- - - - - - - - -
-
- - -
-
-
- ) -} diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx new file mode 100644 index 0000000..1d8e96d --- /dev/null +++ b/src/pages/blogs/index.tsx @@ -0,0 +1,205 @@ +import { useMemo, useRef, useState } from 'react' +import { useLoaderData, useSearchParams } from 'react-router-dom' +import { useLocalStorage } from 'hooks' +import { BlogCardDetails, NSFWFilter, SortBy } from 'types' +import { SearchInput } from '../../components/SearchInput' +import { BlogCard } from '../../components/BlogCard' +import '../../styles/filters.css' +import '../../styles/pagination.css' +import '../../styles/search.css' +import '../../styles/styles.css' + +export const BlogsPage = () => { + const blogs = useLoaderData() as Partial[] | undefined + const [filterOptions, setFilterOptions] = useLocalStorage('filter-blog', { + sort: SortBy.Latest, + nsfw: NSFWFilter.Hide_NSFW + }) + + // Search + const searchTermRef = useRef(null) + const [searchParams, setSearchParams] = useSearchParams() + const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '') + const handleSearch = () => { + const value = searchTermRef.current?.value || '' // Access the input value from the ref + setSearchTerm(value) + + if (value) { + searchParams.set('q', value) + } else { + searchParams.delete('q') + } + + setSearchParams(searchParams, { + replace: true + }) + } + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch() + } + } + + // Filter + const filteredBlogs = useMemo(() => { + const filterNsfwFn = (blog: Partial) => { + switch (filterOptions.nsfw) { + case NSFWFilter.Hide_NSFW: + return !blog.nsfw + case NSFWFilter.Only_NSFW: + return blog.nsfw + default: + return blog + } + } + + let filtered = blogs?.filter(filterNsfwFn) || [] + const lowerCaseSearchTerm = searchTerm.toLowerCase() + + if (searchTerm !== '') { + const filterSearchTermFn = (blog: Partial) => + (blog.title || '').toLowerCase().includes(lowerCaseSearchTerm) || + (blog.summary || '').toLowerCase().includes(lowerCaseSearchTerm) || + (blog.content || '').toLowerCase().includes(lowerCaseSearchTerm) || + (blog.tTags || []).findIndex((tag) => + tag.toLowerCase().includes(lowerCaseSearchTerm) + ) > -1 + filtered = filtered.filter(filterSearchTermFn) + } + + if (filterOptions.sort === SortBy.Latest) { + filtered.sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + } else if (filterOptions.sort === SortBy.Oldest) { + filtered.sort((a, b) => + a.published_at && b.published_at ? a.published_at - b.published_at : 0 + ) + } + + return filtered + }, [blogs, searchTerm, filterOptions.sort, filterOptions.nsfw]) + + return ( +
+
+
+
+
+
+

Blogs

+
+ +
+
+ +
+
+
+
+ +
+ {Object.values(SortBy).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + sort: item + })) + } + > + {item} +
+ ))} +
+
+
+
+
+ +
+ {Object.values(NSFWFilter).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + nsfw: item + })) + } + > + {item} +
+ ))} +
+
+
+
+ +
+
+ {filteredBlogs && + filteredBlogs.map((b) => )} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/pages/blogs/loader.ts b/src/pages/blogs/loader.ts new file mode 100644 index 0000000..74066f5 --- /dev/null +++ b/src/pages/blogs/loader.ts @@ -0,0 +1,37 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds } from 'nostr-tools' +import { toast } from 'react-toastify' +import { log, LogType, npubToHex } from 'utils' +import { extractBlogCardDetails } from 'utils/blog' + +export const blogsRouteLoader = (ndkContext: NDKContextType) => async () => { + try { + const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') + const blogHexkeys = blogNpubs + .map(npubToHex) + .filter((hexkey) => hexkey !== null) + + const filter: NDKFilter = { + authors: blogHexkeys, + kinds: [kinds.LongFormArticle] + } + const events = await ndkContext.fetchEvents(filter) + + if (!events) { + log(true, LogType.Error, 'Unable to fetch the blog events.') + return null + } + + return events.map(extractBlogCardDetails).filter((e) => e.naddr) + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + toast.error('An error occurred in fetching blog details from relays') + return null + } +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5c15e25..903c207 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -119,10 +119,10 @@ export const HomePage = () => {

Blog Posts (WIP)

- - - - + + + +
diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 5aa9bf9..580ba24 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -2,7 +2,6 @@ import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import { formatDate } from 'date-fns' import FsLightbox from 'fslightbox-react' import { nip19, UnsignedEvent } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' @@ -31,7 +30,6 @@ import '../../styles/tags.css' import '../../styles/write.css' import { DownloadUrl, ModDetails, UserRelaysType } from '../../types' import { - abbreviateNumber, copyTextToClipboard, downloadFile, extractModData, @@ -43,11 +41,11 @@ import { sendDMUsingRandomKey, signAndPublish } from '../../utils' -import { Comments } from './internal/comment' -import { Reactions } from './internal/reactions' -import { Zap } from './internal/zap' +import { Comments } from '../../components/comment' import { CheckboxField } from 'components/Inputs' import placeholder from '../../assets/img/DEGMods Placeholder Img.png' +import { PublishDetails } from 'components/Internal/PublishDetails' +import { Interactions } from 'components/Internal/Interactions' export const ModPage = () => { const { naddr } = useParams() @@ -143,7 +141,7 @@ export const ModPage = () => { nsfw={modData.nsfw} /> { Creator's Blog Posts (WIP)
- - - + + +
@@ -972,126 +970,6 @@ const Body = ({ ) } -type InteractionsProps = { - modDetails: ModDetails - commentCount: number -} - -const Interactions = ({ modDetails, commentCount }: InteractionsProps) => { - return ( - - ) -} - -type PublishDetailsProps = { - published_at: number - edited_at: number - site: string -} - -const PublishDetails = ({ - published_at, - edited_at, - site -}: PublishDetailsProps) => { - return ( -
-
-
- - - -

- {formatDate( - (published_at !== -1 ? published_at : edited_at) * 1000, - 'dd/MM/yyyy hh:mm:ss aa' - )} -

-
-
- - - -

- {formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')} -

-
- - - - -

{site}

-
-
-
- ) -} - const Download = ({ url, hash, diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx index 3dfb1a5..ae16b2b 100644 --- a/src/pages/write/action.tsx +++ b/src/pages/write/action.tsx @@ -1,7 +1,7 @@ import { NDKContextType } from 'contexts/NDKContext' import { ActionFunctionArgs, redirect } from 'react-router-dom' import { getBlogPageRoute } from 'routes' -import { BlogFormErrors, BlogFormSubmit } from 'types' +import { BlogFormErrors, BlogEventSubmitForm } from 'types' import { isReachable, isValidImageUrl, @@ -19,7 +19,7 @@ import { store } from 'store' export const writeRouteAction = (ndkContext: NDKContextType) => - async ({ params, request }: ActionFunctionArgs) => { + async ({ request }: ActionFunctionArgs) => { // Get the current state const userState = store.getState().user let hexPubkey: string @@ -47,7 +47,7 @@ export const writeRouteAction = const formData = await request.formData() // Parse the the data - const formSubmit = parseFormData(formData) + const formSubmit = parseFormData(formData) // Check for errors const formErrors = await validateFormData(formSubmit) @@ -110,7 +110,7 @@ export const writeRouteAction = )}` ) const naddr = nip19.naddrEncode({ - identifier: aTag, + identifier: uuid, pubkey: signedEvent.pubkey, kind: signedEvent.kind, relays: publishedOnRelays @@ -125,7 +125,7 @@ export const writeRouteAction = } const validateFormData = async ( - formData: Partial + formData: Partial ): Promise => { const errors: BlogFormErrors = {} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index e875d86..6c7e88a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -20,6 +20,7 @@ import { writeRouteAction } from '../pages/write/action' import { BlogsPage } from 'pages/blogs' import { BlogPage } from 'pages/blog' import { blogRouteLoader } from 'pages/blog/loader' +import { blogsRouteLoader } from 'pages/blogs/loader' export const appRoutes = { index: '/', @@ -29,8 +30,8 @@ export const appRoutes = { mods: '/mods', mod: '/mod/:naddr', about: '/about', - blogs: '/blogs', - blog: '/blogs/:naddr', + blogs: '/blog', + blog: '/blog/:naddr', submitMod: '/submit-mod', editMod: '/edit-mod/:naddr', write: '/write', @@ -90,7 +91,8 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.blogs, - element: + element: , + loader: blogsRouteLoader(context) }, { path: appRoutes.blog, diff --git a/src/types/blog.ts b/src/types/blog.ts index bedb751..051c153 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -1,11 +1,13 @@ -export interface BlogDetails { +export interface BlogForm { title: string content: string - summary: string image: string - nsfw: boolean + summary: string tags: string + nsfw: boolean +} +export interface BlogDetails extends BlogForm { id: string author: string published_at: number @@ -16,20 +18,12 @@ export interface BlogDetails { tTags: string[] } -export interface BlogFormSubmit - extends Omit< - BlogDetails, - | 'nsfw' - | 'id' - | 'author' - | 'published_at' - | 'edited_at' - | 'rTag' - | 'dTag' - | 'aTag' - | 'tTag' - > { +export interface BlogEventSubmitForm extends Omit { nsfw: string } -export interface BlogFormErrors extends Partial {} +export interface BlogFormErrors extends Partial {} + +export interface BlogCardDetails extends BlogDetails { + naddr: string +} diff --git a/src/types/nostr.ts b/src/types/nostr.ts index 00e5ade..bdf46a4 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -7,3 +7,9 @@ export interface SignedEvent { id: string sig: string } + +export interface Addressable { + author: string + id: string + aTag: string +} diff --git a/src/utils/blog.ts b/src/utils/blog.ts new file mode 100644 index 0000000..0de30db --- /dev/null +++ b/src/utils/blog.ts @@ -0,0 +1,38 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { BlogCardDetails, BlogDetails } from 'types' +import { getFirstTagValue, getFirstTagValueAsInt, getTagValue } from './nostr' +import { kinds, nip19 } from 'nostr-tools' + +export const extractBlogDetails = (event: NDKEvent): Partial => ({ + title: getFirstTagValue(event, 'title'), + content: event.content, + summary: getFirstTagValue(event, 'summary'), + image: getFirstTagValue(event, 'image'), + nsfw: getFirstTagValue(event, 'nsfw') === 'true', + + id: event.id, + author: event.pubkey, + published_at: getFirstTagValueAsInt(event, 'published_at'), + edited_at: event.created_at, + rTag: getFirstTagValue(event, 'r') || 'N/A', + dTag: getFirstTagValue(event, 'd'), + aTag: getFirstTagValue(event, 'a'), + tTags: getTagValue(event, 't') || [] +}) + +export const extractBlogCardDetails = ( + event: NDKEvent +): Partial => { + const blogDetails = extractBlogDetails(event) + + return { + ...blogDetails, + naddr: blogDetails.dTag + ? nip19.naddrEncode({ + identifier: blogDetails.dTag, + kind: kinds.LongFormArticle, + pubkey: event.pubkey + }) + : undefined + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1f3c47c..6363e3e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_REPORTING_NPUB: string readonly VITE_FALLBACK_MOD_IMAGE: string readonly VITE_FALLBACK_GAME_IMAGE: string + readonly VITE_BLOG_NPUBS: string // more env variables... } From 73a7b1c1ee69ea556fc0562abfecb517e7845e25 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 14:11:11 +0100 Subject: [PATCH 05/19] feat: admin blog page pagination --- src/pages/blogs/index.tsx | 77 +++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 1d8e96d..29c8506 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -8,6 +8,8 @@ import '../../styles/filters.css' import '../../styles/pagination.css' import '../../styles/search.css' import '../../styles/styles.css' +import { PaginationWithPageNumbers } from 'components/Pagination' +import { scrollIntoView } from 'utils' export const BlogsPage = () => { const blogs = useLoaderData() as Partial[] | undefined @@ -80,10 +82,31 @@ export const BlogsPage = () => { return filtered }, [blogs, searchTerm, filterOptions.sort, filterOptions.nsfw]) + // Pagination logic + const [currentPage, setCurrentPage] = useState(1) + const scrollTargetRef = useRef(null) + + const MAX_BLOGS_PER_PAGE = 16 + const totalBlogs = filteredBlogs.length + const totalPages = Math.ceil(totalBlogs / MAX_BLOGS_PER_PAGE) + const startIndex = (currentPage - 1) * MAX_BLOGS_PER_PAGE + const endIndex = startIndex + MAX_BLOGS_PER_PAGE + const currentMods = filteredBlogs.slice(startIndex, endIndex) + + const handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages) { + scrollIntoView(scrollTargetRef.current) + setCurrentPage(page) + } + } + return (
-
+
@@ -156,48 +179,22 @@ export const BlogsPage = () => {
+
-
-
- {filteredBlogs && - filteredBlogs.map((b) => )} -
-
- -
- +
+
+ {currentMods && + currentMods.map((b) => )}
+ + {totalPages > 1 && ( + + )}
From 169ab373043b91d6f36b6d33b689be9a01311469 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 14:42:22 +0100 Subject: [PATCH 06/19] fix: get multiple tag values --- src/utils/blog.ts | 4 ++-- src/utils/nostr.ts | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/blog.ts b/src/utils/blog.ts index 0de30db..df11b63 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -1,6 +1,6 @@ import { NDKEvent } from '@nostr-dev-kit/ndk' import { BlogCardDetails, BlogDetails } from 'types' -import { getFirstTagValue, getFirstTagValueAsInt, getTagValue } from './nostr' +import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr' import { kinds, nip19 } from 'nostr-tools' export const extractBlogDetails = (event: NDKEvent): Partial => ({ @@ -17,7 +17,7 @@ export const extractBlogDetails = (event: NDKEvent): Partial => ({ rTag: getFirstTagValue(event, 'r') || 'N/A', dTag: getFirstTagValue(event, 'd'), aTag: getFirstTagValue(event, 'a'), - tTags: getTagValue(event, 't') || [] + tTags: getTagValues(event, 't') || [] }) export const extractBlogCardDetails = ( diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index b1fd0e1..772c37b 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -65,11 +65,27 @@ export const getTagValue = ( return null } +export const getTagValues = ( + event: Event | NDKEvent, + tagIdentifier: string +): string[] | null => { + // Find the tag in the event's tags array where the first element matches the tagIdentifier. + const tags = event.tags.filter((item) => item[0] === tagIdentifier) + + // If a matching tag is found, return the rest of the elements in the tag (i.e., the values). + if (tags && tags.length) { + return tags.map((item) => item[1]) // Returning only the values + } + + // Return null if no matching tag is found. + return null +} + export const getFirstTagValue = ( event: Event | NDKEvent, tagIdentifier: string ) => { - const tags = getTagValue(event, tagIdentifier) + const tags = getTagValues(event, tagIdentifier) return tags && tags.length ? tags[0] : undefined } From a3bec707b0f377509fbb3350f695ad96c699ed99 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:22:08 +0100 Subject: [PATCH 07/19] feat(landing): show latest blog posts --- src/constants.ts | 3 +- src/pages/blog/loader.ts | 3 +- src/pages/blogs/loader.ts | 2 - src/pages/home.tsx | 142 ++++++++++++++++++++++++++++++-------- 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index e2838b0..8b91a18 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,7 +20,8 @@ export const LANDING_PAGE_DATA = { 'Cyberpunk 2077', 'ELDEN RING', 'The Coffin of Andy and Leyley' - ] + ], + featuredBlogPosts: [] } // we use this object to check if a user has reacted positively or negatively to a post // reactions are kind 7 events and their content is either emoji icon or emoji shortcode diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index 4cd0338..f4313c7 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -17,11 +17,11 @@ export const blogRouteLoader = try { const filter = filterForEventsTaggingId(naddr) - if (!filter) { log(true, LogType.Error, 'Unable to create filter from blog naddr.') return redirect(appRoutes.blogs) } + const event = await ndkContext.fetchEvent(filter) if (!event) { log(true, LogType.Error, 'Unable to fetch the blog event.') @@ -29,7 +29,6 @@ export const blogRouteLoader = } const blogDetails = extractBlogDetails(event) - return blogDetails } catch (error) { log( diff --git a/src/pages/blogs/loader.ts b/src/pages/blogs/loader.ts index 74066f5..f2ab84d 100644 --- a/src/pages/blogs/loader.ts +++ b/src/pages/blogs/loader.ts @@ -1,7 +1,6 @@ import { NDKFilter } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' import { kinds } from 'nostr-tools' -import { toast } from 'react-toastify' import { log, LogType, npubToHex } from 'utils' import { extractBlogCardDetails } from 'utils/blog' @@ -31,7 +30,6 @@ export const blogsRouteLoader = (ndkContext: NDKContextType) => async () => { 'An error occurred in fetching blog details from relays', error ) - toast.error('An error occurred in fetching blog details from relays') return null } } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 903c207..3346d7e 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,6 +1,6 @@ -import { nip19 } from 'nostr-tools' +import { kinds, nip19 } from 'nostr-tools' import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' import { Swiper, SwiperSlide } from 'swiper/react' import { BlogCard } from '../components/BlogCard' @@ -15,19 +15,25 @@ import { useNSFWList } from '../hooks' import { appRoutes, getModPageRoute } from '../routes' -import { ModDetails } from '../types' -import { extractModData, handleModImageError, log, LogType } from '../utils' +import { BlogCardDetails, ModDetails } from '../types' +import { + extractModData, + handleModImageError, + log, + LogType, + npubToHex +} from '../utils' import '../styles/cardLists.css' import '../styles/SimpleSlider.css' import '../styles/styles.css' // Import Swiper styles -import { NDKFilter } from '@nostr-dev-kit/ndk' +import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' -import placeholder from '../assets/img/DEGMods Placeholder Img.png' +import { extractBlogCardDetails } from 'utils/blog' export const HomePage = () => { const navigate = useNavigate() @@ -114,27 +120,7 @@ export const HomePage = () => {
-
-
-

Blog Posts (WIP)

-
-
- - - - -
- - -
+
@@ -327,3 +313,105 @@ const Spinner = () => { ) } + +const DisplayLatestBlogs = () => { + const [blogs, setBlogs] = useState[]>() + const { fetchEvents } = useNDKContext() + + useDidMount(() => { + const fetchBlogs = async () => { + try { + // Show maximum of 4 blog posts + // 2 should be featured and the most recent 2 from blog npubs + + // Populate the filter from known naddr (constants.ts) + const filters: NDKFilter[] = [] + for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) { + try { + const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i] + const filterId = filterForEventsTaggingId(naddr) + if (filterId) { + filters.push(filterId) + } + } catch (error) { + // Silently ignore + } + } + // Create a single filter based on multiple #a's + const filter = filters.reduce( + (filter, id) => { + const a = id['#a'] + if (a) { + filter['#a']?.push(a[0]) + } + return filter + }, + { + '#a': [] + } as NDKFilter + ) + // Fetch featured blogs posts + const featuredBlogPosts = await fetchEvents(filter) + + // Fetch latest blog npubs posts + const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') + const blogHexkeys = blogNpubs + .map(npubToHex) + .filter((hexkey) => hexkey !== null) + + // We fetch 4 posts in case of duplicates (from featured) + const latestBlogPosts = await fetchEvents({ + authors: blogHexkeys, + kinds: [kinds.LongFormArticle], + limit: 4 + }) + + // Remove duplicates + const unique = Array.from( + [...featuredBlogPosts, ...latestBlogPosts] + .reduce((map, obj) => { + map.set(obj.id, obj) + return map + }, new Map()) + .values() + ) + const latest = unique.slice(0, 4) + + setBlogs(latest.map(extractBlogCardDetails)) + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + return null + } + } + + fetchBlogs() + }) + + return ( +
+
+

Blog Posts

+
+
+ {blogs?.map((b) => ( + + ))} +
+ +
+ + View All + +
+
+ ) +} From 1d0f27d2557ae16326406185575a3f507aa26b04 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:43:08 +0100 Subject: [PATCH 08/19] feat(mod): show latest mod author blog posts --- src/pages/mod/index.tsx | 69 ++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 580ba24..7012534 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -3,7 +3,7 @@ import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import FsLightbox from 'fslightbox-react' -import { nip19, UnsignedEvent } from 'nostr-tools' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { Link as ReactRouterLink, useParams } from 'react-router-dom' import { toast } from 'react-toastify' @@ -28,7 +28,12 @@ import '../../styles/styles.css' import '../../styles/tabs.css' import '../../styles/tags.css' import '../../styles/write.css' -import { DownloadUrl, ModDetails, UserRelaysType } from '../../types' +import { + BlogCardDetails, + DownloadUrl, + ModDetails, + UserRelaysType +} from '../../types' import { copyTextToClipboard, downloadFile, @@ -46,6 +51,7 @@ import { CheckboxField } from 'components/Inputs' import placeholder from '../../assets/img/DEGMods Placeholder Img.png' import { PublishDetails } from 'components/Internal/PublishDetails' import { Interactions } from 'components/Internal/Interactions' +import { extractBlogCardDetails } from 'utils/blog' export const ModPage = () => { const { naddr } = useParams() @@ -189,18 +195,7 @@ export const ModPage = () => { )} -
-
-

- Creator's Blog Posts (WIP) -

-
- - - -
-
-
+
) } + +const DisplayModAuthorBlogs = () => { + const { naddr } = useParams() + const [blogs, setBlogs] = useState[]>() + const { fetchEvents } = useNDKContext() + + useDidMount(() => { + const fetchBlogs = async () => { + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { pubkey } = decoded.data + const latestBlogPosts = await fetchEvents({ + authors: [pubkey], + kinds: [kinds.LongFormArticle], + limit: 3 + }) + setBlogs(latestBlogPosts.map(extractBlogCardDetails)) + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + return null + } + } + + fetchBlogs() + }) + + if (!blogs?.length) return null + + return ( +
+
+

Creator's Blog Posts

+
+ {blogs?.map((b) => ( + + ))} +
+
+
+ ) +} From 2f32f400ddb6a0f3794bca2a59bc16fc92bce627 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:44:07 +0100 Subject: [PATCH 09/19] refactor(mod): remove unused import --- src/pages/mod/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 7012534..dc34ca6 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -48,7 +48,6 @@ import { } from '../../utils' import { Comments } from '../../components/comment' import { CheckboxField } from 'components/Inputs' -import placeholder from '../../assets/img/DEGMods Placeholder Img.png' import { PublishDetails } from 'components/Internal/PublishDetails' import { Interactions } from 'components/Internal/Interactions' import { extractBlogCardDetails } from 'utils/blog' From dae94733fa9376ef979f0c9981225eff041a8c62 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 11:13:42 +0100 Subject: [PATCH 10/19] feat: add react router scroll restoration --- src/layout/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layout/index.tsx b/src/layout/index.tsx index a644ced..cacabd4 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -1,4 +1,4 @@ -import { Outlet } from 'react-router-dom' +import { Outlet, ScrollRestoration } from 'react-router-dom' import { Footer } from './footer' import { Header } from './header' import { SocialNav } from './socialNav' @@ -12,6 +12,7 @@ export const Layout = () => {
+ ) } From c81b2c0a1d55b9eeaca613323e692701cd969ea7 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:12:19 +0100 Subject: [PATCH 11/19] refactor(home): fetch latest blogs in parallel w/ nsfw filter --- src/pages/home.tsx | 50 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 3346d7e..5532e22 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -10,13 +10,15 @@ import { LANDING_PAGE_DATA } from '../constants' import { useDidMount, useGames, + useLocalStorage, useMuteLists, useNDKContext, useNSFWList } from '../hooks' import { appRoutes, getModPageRoute } from '../routes' -import { BlogCardDetails, ModDetails } from '../types' +import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types' import { + extractBlogCardDetails, extractModData, handleModImageError, log, @@ -29,11 +31,14 @@ import '../styles/SimpleSlider.css' import '../styles/styles.css' // Import Swiper styles -import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' +import { + filterForEventsTaggingId, + NDKEvent, + NDKFilter +} from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' -import { extractBlogCardDetails } from 'utils/blog' export const HomePage = () => { const navigate = useNavigate() @@ -317,13 +322,15 @@ const Spinner = () => { const DisplayLatestBlogs = () => { const [blogs, setBlogs] = useState[]>() const { fetchEvents } = useNDKContext() - + const [filterOptions] = useLocalStorage('filter-blog-curated', { + sort: SortBy.Latest, + nsfw: NSFWFilter.Hide_NSFW + }) useDidMount(() => { const fetchBlogs = async () => { try { // Show maximum of 4 blog posts // 2 should be featured and the most recent 2 from blog npubs - // Populate the filter from known naddr (constants.ts) const filters: NDKFilter[] = [] for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) { @@ -350,25 +357,46 @@ const DisplayLatestBlogs = () => { '#a': [] } as NDKFilter ) - // Fetch featured blogs posts - const featuredBlogPosts = await fetchEvents(filter) - - // Fetch latest blog npubs posts + // Prepare filter for the latest const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') const blogHexkeys = blogNpubs .map(npubToHex) .filter((hexkey) => hexkey !== null) // We fetch 4 posts in case of duplicates (from featured) - const latestBlogPosts = await fetchEvents({ + const latestFilter: NDKFilter = { authors: blogHexkeys, kinds: [kinds.LongFormArticle], limit: 4 + } + + // Filter by NSFW tag + // NSFWFilter.Show_NSFW -> filter not needed + // NSFWFilter.Only_NSFW -> true + // NSFWFilter.Hide_NSFW -> false + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + latestFilter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + const results = await Promise.allSettled([ + fetchEvents(filter), + fetchEvents(latestFilter) + ]) + + const events: NDKEvent[] = [] + // Get featured blogs posts result + results.forEach((r) => { + // Add events from both promises to the array + if (r.status === 'fulfilled' && r.value) { + events.push(...r.value) + } }) // Remove duplicates const unique = Array.from( - [...featuredBlogPosts, ...latestBlogPosts] + events .reduce((map, obj) => { map.set(obj.id, obj) return map From 31ee0221b7664b7c006240b54a1ff97f5a6ad198 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:15:30 +0100 Subject: [PATCH 12/19] refactor(blogs): curated filter type --- src/pages/blogs/index.tsx | 11 +++++++---- src/utils/index.ts | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 29c8506..375f1a8 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -13,10 +13,13 @@ import { scrollIntoView } from 'utils' export const BlogsPage = () => { const blogs = useLoaderData() as Partial[] | undefined - const [filterOptions, setFilterOptions] = useLocalStorage('filter-blog', { - sort: SortBy.Latest, - nsfw: NSFWFilter.Hide_NSFW - }) + const [filterOptions, setFilterOptions] = useLocalStorage( + 'filter-blog-curated', + { + sort: SortBy.Latest, + nsfw: NSFWFilter.Hide_NSFW + } + ) // Search const searchTermRef = useRef(null) diff --git a/src/utils/index.ts b/src/utils/index.ts index 06e7ca4..3391eb9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './utils' export * from './zap' export * from './localStorage' export * from './consts' +export * from './blog' From f30ac01ea649296d92663ce753d4918914cab022 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:17:13 +0100 Subject: [PATCH 13/19] refactor(blog): re-render body, latest and filtering --- src/pages/blog/index.tsx | 98 +++++++++++++++++++--------------------- src/pages/blog/loader.ts | 90 ++++++++++++++++++++++++++++++++---- src/types/blog.ts | 5 ++ 3 files changed, 132 insertions(+), 61 deletions(-) diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 46becf8..7fb5814 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -9,37 +9,41 @@ import { marked } from 'marked' import { LoadingSpinner } from 'components/LoadingSpinner' import { ProfileSection } from 'components/ProfileSection' import { Comments } from 'components/comment' -import { Addressable, BlogDetails } from 'types' +import { Addressable, BlogPageLoaderResult } from 'types' import placeholder from '../../assets/img/DEGMods Placeholder Img.png' import { PublishDetails } from 'components/Internal/PublishDetails' import { Interactions } from 'components/Internal/Interactions' +import { BlogCard } from 'components/BlogCard' export const BlogPage = () => { - const data = useLoaderData() as Partial + const { blog, latest } = useLoaderData() as BlogPageLoaderResult const [commentCount, setCommentCount] = useState(0) - const html = marked.parse(data?.content || '', { async: false }) + 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 - }) + const editor = useEditor( + { + content: sanitized, + extensions: [ + StarterKit, + Link, + Image.configure({ + inline: true, + HTMLAttributes: { + class: 'IBMSMSMBSSPostImg' + } + }) + ], + editable: false + }, + [sanitized] + ) return (
- {!data ? ( + {!blog ? ( ) : (
@@ -67,27 +71,27 @@ export const BlogPage = () => { className='IBMSMSMBSSPostPicture' style={{ background: `url("${ - data.image !== '' ? data.image : placeholder + blog.image !== '' ? blog.image : placeholder }") center / cover no-repeat` }} >

- {data.title} + {blog.title}

- {data.nsfw && ( + {blog.nsfw && (

NSFW

)} - {data.tTags && - data.tTags.map((t) => ( + {blog.tTags && + blog.tTags.map((t) => ( {t} @@ -96,48 +100,38 @@ export const BlogPage = () => {
- {/*
)} - {!!data?.author && } + {!!blog?.author && }
diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index f4313c7..e74c68e 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -1,10 +1,17 @@ -import { filterForEventsTaggingId } from '@nostr-dev-kit/ndk' +import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes } from 'routes' -import { log, LogType } from 'utils' -import { extractBlogDetails } from 'utils/blog' +import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' +import { + DEFAULT_FILTER_OPTIONS, + getLocalStorageItem, + log, + LogType +} from 'utils' +import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog' export const blogRouteLoader = (ndkContext: NDKContextType) => @@ -15,21 +22,86 @@ export const blogRouteLoader = return redirect(appRoutes.blogs) } + // Decode author from naddr + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { pubkey } = decoded.data + try { + // Get the filter with #a from naddr for the main blog content const filter = filterForEventsTaggingId(naddr) if (!filter) { log(true, LogType.Error, 'Unable to create filter from blog naddr.') return redirect(appRoutes.blogs) } - const event = await ndkContext.fetchEvent(filter) - if (!event) { - log(true, LogType.Error, 'Unable to fetch the blog event.') - return null + // Get the blog filter options for latest blogs + const filterOptions = JSON.parse( + getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS) + ) as FilterOptions + + // Fetch 4 in case the current blog is included in the latest + const latestModsFilter: NDKFilter = { + authors: [pubkey], + kinds: [kinds.LongFormArticle], + limit: 4 + } + // Add source filter + if (filterOptions.source === window.location.host) { + latestModsFilter['#r'] = [filterOptions.source] + } + // Filter by NSFW tag + // NSFWFilter.Show_NSFW -> filter not needed + // NSFWFilter.Only_NSFW -> true + // NSFWFilter.Hide_NSFW -> false + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + latestModsFilter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] } - const blogDetails = extractBlogDetails(event) - return blogDetails + // Parallel fetch blog event and latest events + const settled = await Promise.allSettled([ + ndkContext.fetchEvent(filter), + ndkContext.fetchEvents(latestModsFilter) + ]) + + const result: BlogPageLoaderResult = { + blog: undefined, + latest: [] + } + + // Check the blog event result + const fetchEventResult = settled[0] + if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) { + // Extract the blog details from the event + result.blog = extractBlogDetails(fetchEventResult.value) + } else if (fetchEventResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the blog event.', + fetchEventResult.reason + ) + } + + // Check the lateast blog events + const fetchEventsResult = settled[1] + if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { + // Extract the blog card details from the events + result.latest = fetchEventsResult.value + .map(extractBlogCardDetails) + .filter((b) => b.id !== result.blog?.id) // Filter out current blog if present + .slice(0, 3) // Take only three + } else if (fetchEventsResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the latest blog events.', + fetchEventsResult.reason + ) + } + + return result } catch (error) { log( true, diff --git a/src/types/blog.ts b/src/types/blog.ts index 051c153..6e5b8b8 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -27,3 +27,8 @@ export interface BlogFormErrors extends Partial {} export interface BlogCardDetails extends BlogDetails { naddr: string } + +export interface BlogPageLoaderResult { + blog: Partial | undefined + latest: Partial[] +} From 6d6ff8ce434ccd8a491bd0fb9b1ebcb4411009fb Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 17:33:15 +0100 Subject: [PATCH 14/19] feat(profile): blogs tab --- src/constants.ts | 3 +- src/pages/{profile.tsx => profile/index.tsx} | 194 +++++++++++++++---- src/pages/profile/loader.ts | 91 +++++++++ src/routes/index.tsx | 6 +- 4 files changed, 251 insertions(+), 43 deletions(-) rename src/pages/{profile.tsx => profile/index.tsx} (83%) create mode 100644 src/pages/profile/loader.ts diff --git a/src/constants.ts b/src/constants.ts index 8b91a18..d4a56d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -113,7 +113,8 @@ export const REACTIONS = { export const MAX_MODS_PER_PAGE = 10 export const MAX_GAMES_PER_PAGE = 10 - // todo: add game and mod fallback image here export const FALLBACK_PROFILE_IMAGE = 'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png' + +export const PROFILE_BLOG_FILTER_LIMIT = 20 diff --git a/src/pages/profile.tsx b/src/pages/profile/index.tsx similarity index 83% rename from src/pages/profile.tsx rename to src/pages/profile/index.tsx index 84e2293..6f1c305 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile/index.tsx @@ -5,7 +5,7 @@ import { ModFilter } from 'components/ModsFilter' import { Pagination } from 'components/Pagination' import { ProfileSection } from 'components/ProfileSection' import { Tabs } from 'components/Tabs' -import { MOD_FILTER_LIMIT } from '../constants' +import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { useAppSelector, useFilteredMods, @@ -14,15 +14,23 @@ import { useNDKContext, useNSFWList } from 'hooks' -import { nip19, UnsignedEvent } from 'nostr-tools' -import { useCallback, useEffect, useRef, useState } from 'react' -import { useParams, Navigate, Link } from 'react-router-dom' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes, getProfilePageRoute } from 'routes' -import { FilterOptions, ModDetails, UserRelaysType } from 'types' +import { + BlogCardDetails, + FilterOptions, + ModDetails, + NSFWFilter, + SortBy, + UserRelaysType +} from 'types' import { copyTextToClipboard, DEFAULT_FILTER_OPTIONS, + extractBlogCardDetails, log, LogType, now, @@ -32,9 +40,15 @@ import { signAndPublish } from 'utils' import { CheckboxField } from 'components/Inputs' -import { useProfile } from 'hooks/useProfile' +import { ProfilePageLoaderResult } from './loader' +import { BlogCard } from 'components/BlogCard' export const ProfilePage = () => { + const { + profile, + isBlocked: _isBlocked, + isOwnProfile + } = useLoaderData() as ProfilePageLoaderResult // Try to decode nprofile parameter const { nprofile } = useParams() let profilePubkey: string | undefined @@ -51,46 +65,14 @@ export const ProfilePage = () => { const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const userState = useAppSelector((state) => state.user) - const isOwnProfile = - userState.auth && userState.user?.pubkey === profilePubkey const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const profile = useProfile(profilePubkey) - const displayName = profile?.displayName || profile?.name || '[name not set up]' const [showReportPopUp, setShowReportPopUp] = useState(false) - const [isBlocked, setIsBlocked] = useState(false) - useEffect(() => { - if (userState.auth && userState.user?.pubkey) { - const userHexKey = userState.user.pubkey as string - - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [userHexKey] - } - - fetchEventFromUserRelays( - muteListFilter, - userHexKey, - UserRelaysType.Write - ).then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const blocked = - tags.findIndex( - (item) => item[0] === 'p' && item[1] === profilePubkey - ) !== -1 - - setIsBlocked(blocked) - } - }) - } - }, [userState, profilePubkey, fetchEventFromUserRelays]) - + const [isBlocked, setIsBlocked] = useState(_isBlocked) const handleBlock = async () => { if (!profilePubkey) { toast.error(`Something went wrong. Unable to find reported user's pubkey`) @@ -482,7 +464,7 @@ export const ProfilePage = () => { )} - {tab === 1 && <>WIP} + {tab === 1 && } {tab === 2 && <>WIP}
@@ -703,3 +685,135 @@ const ReportUserPopup = ({ ) } + +const ProfileTabBlogs = () => { + const { profile } = useLoaderData() as ProfilePageLoaderResult + const { fetchEvents } = useNDKContext() + const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) + const [isLoading, setIsLoading] = useState(true) + const blogfilter: NDKFilter = useMemo(() => { + const filter: NDKFilter = { + authors: [profile?.pubkey as string], + kinds: [kinds.LongFormArticle] + } + + const host = window.location.host + if (filterOptions.source === host) { + filter['#r'] = [host] + } + + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + filter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + return filter + }, [filterOptions.nsfw, filterOptions.source, profile?.pubkey]) + + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [blogs, setBlogs] = useState[]>([]) + useEffect(() => { + if (profile) { + // Initial blog fetch, go beyond limit to check for next + const filter: NDKFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1 + } + fetchEvents(filter) + .then((events) => { + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [blogfilter, fetchEvents, profile]) + + const handleNext = useCallback(() => { + if (isLoading) return + + const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined + if (last?.published_at) { + const until = last?.published_at - 1 + const nextFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1, + until + } + setIsLoading(true) + fetchEvents(nextFilter) + .then((events) => { + const nextBlogs = events.map(extractBlogCardDetails) + setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) + setPage((prev) => prev + 1) + setBlogs( + nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) + ) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const handlePrev = useCallback(() => { + if (isLoading) return + + const first = blogs.length > 0 ? blogs[0] : undefined + if (first?.published_at) { + const since = first.published_at + 1 + const prevFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT, + since + } + setIsLoading(true) + fetchEvents(prevFilter) + .then((events) => { + setHasMore(true) + setPage((prev) => prev - 1) + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const sortedBlogs = useMemo(() => { + const sorted = blogs || [] + if (filterOptions.sort === SortBy.Latest) { + sorted.sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + } else if (filterOptions.sort === SortBy.Oldest) { + sorted.sort((a, b) => + a.published_at && b.published_at ? a.published_at - b.published_at : 0 + ) + } + + return sorted + }, [blogs, filterOptions.sort]) + + return ( + <> + {isLoading && } + + + +
+ {sortedBlogs.map((b) => ( + + ))} +
+ + {!(page === 1 && !hasMore) && ( + + )} + + ) +} diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts new file mode 100644 index 0000000..3c80254 --- /dev/null +++ b/src/pages/profile/loader.ts @@ -0,0 +1,91 @@ +import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { nip19 } from 'nostr-tools' +import { LoaderFunctionArgs, redirect } from 'react-router-dom' +import { appRoutes, getProfilePageRoute } from 'routes' +import { store } from 'store' +import { UserProfile, UserRelaysType } from 'types' +import { log, LogType } from 'utils' + +export interface ProfilePageLoaderResult { + profile: UserProfile + isBlocked: boolean + isOwnProfile: boolean +} + +export const profileRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + let profileRoute = appRoutes.home + + // Try to decode nprofile parameter + const { nprofile } = params + let profilePubkey: string | undefined + try { + const value = nprofile + ? nip19.decode(nprofile as `nprofile1${string}`) + : undefined + profilePubkey = value?.data.pubkey + } catch (error) { + // Silently ignore and redirect to home or logged in user + log(true, LogType.Error, 'Failed to decode nprofile.', error) + } + + // Get the current state + const userState = store.getState().user + + // Redirect route + // Redirect home if user is not logged in or profile naddr is missing + if (!profilePubkey && userState.auth && userState.user?.pubkey) { + // Redirect to user's profile is no profile is linked + const userHexKey = userState.user.pubkey as string + + if (userHexKey) { + profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: userHexKey + }) + ) + } + } + + if (!profilePubkey) return redirect(profileRoute) + + const result: ProfilePageLoaderResult = { + profile: {}, + isBlocked: false, + isOwnProfile: false + } + + result.profile = await ndkContext.findMetadata(profilePubkey) + + // Check if user the user is logged in + if (userState.auth && userState.user?.pubkey) { + result.isOwnProfile = userState.user.pubkey === profilePubkey + + const userHexKey = userState.user.pubkey as string + + // Check if user has blocked this profile + const muteListFilter: NDKFilter = { + kinds: [NDKKind.MuteList], + authors: [userHexKey] + } + const muteList = await ndkContext.fetchEventFromUserRelays( + muteListFilter, + userHexKey, + UserRelaysType.Write + ) + if (muteList) { + // get a list of tags + const tags = muteList.tags + const blocked = + tags.findIndex( + (item) => item[0] === 'p' && item[1] === profilePubkey + ) !== -1 + + result.isBlocked = blocked + } + } + + return result + } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 6c7e88a..bab9915 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -8,6 +8,7 @@ import { HomePage } from '../pages/home' import { ModPage } from '../pages/mod' import { ModsPage } from '../pages/mods' import { ProfilePage } from '../pages/profile' +import { profileRouteLoader } from 'pages/profile/loader' import { SettingsPage } from '../pages/settings' import { SubmitModPage } from '../pages/submitMod' import { GamePage } from '../pages/game' @@ -18,9 +19,9 @@ import { NotificationsPage } from '../pages/notifications' import { WritePage } from '../pages/write' import { writeRouteAction } from '../pages/write/action' import { BlogsPage } from 'pages/blogs' +import { blogsRouteLoader } from 'pages/blogs/loader' import { BlogPage } from 'pages/blog' import { blogRouteLoader } from 'pages/blog/loader' -import { blogsRouteLoader } from 'pages/blogs/loader' export const appRoutes = { index: '/', @@ -134,7 +135,8 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.profile, - element: + element: , + loader: profileRouteLoader(context) }, { element: , From f734b1447f30977ad657a235559f900a25df13fd Mon Sep 17 00:00:00 2001 From: freakoverse Date: Wed, 6 Nov 2024 15:11:29 +0000 Subject: [PATCH 15/19] Update src/styles/post.css --- src/styles/post.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/styles/post.css b/src/styles/post.css index faa4863..746f823 100644 --- a/src/styles/post.css +++ b/src/styles/post.css @@ -229,3 +229,12 @@ color: rgba(255,255,255,0.5); } +.dropdown.dropdownMain.dropdownMainBlogpost { + flex-grow: unset; + position: absolute; + top: 10px; + right: 10px; + background: #0000002e; + border-radius: 6px; + padding: 2px; +} \ No newline at end of file From f7f376468602c4d97ffc84685ec583b5f72b70a4 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 7 Nov 2024 17:33:59 +0100 Subject: [PATCH 16/19] feat(blog): moderation and more filtering --- src/pages/blog/action.ts | 264 +++++++++++++++++++++++++ src/pages/blog/index.tsx | 338 +++++++++++++++++++++++++-------- src/pages/blog/loader.ts | 68 ++++++- src/pages/blog/report.tsx | 92 +++++++++ src/pages/blog/reportAction.ts | 146 ++++++++++++++ src/pages/home.tsx | 2 +- src/pages/profile/index.tsx | 65 ++++++- src/pages/profile/loader.ts | 115 +++++++---- src/routes/index.tsx | 17 +- src/types/blog.ts | 2 + 10 files changed, 981 insertions(+), 128 deletions(-) create mode 100644 src/pages/blog/action.ts create mode 100644 src/pages/blog/report.tsx create mode 100644 src/pages/blog/reportAction.ts diff --git a/src/pages/blog/action.ts b/src/pages/blog/action.ts new file mode 100644 index 0000000..696fcdd --- /dev/null +++ b/src/pages/blog/action.ts @@ -0,0 +1,264 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs } from 'react-router-dom' +import { toast } from 'react-toastify' +import { store } from 'store' +import { UserRelaysType } from 'types' +import { log, LogType, now, signAndPublish } from 'utils' + +export const blogRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return null + } + + // Decode author from naddr + let aTag: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { identifier, kind, pubkey } = decoded.data + aTag = `${kind}:${pubkey}:${identifier}` + } catch (error) { + log(true, LogType.Error, 'Failed to decode naddr') + return null + } + + if (!aTag) { + log(true, LogType.Error, 'Missing #a Tag') + return null + } + + const userState = store.getState().user + 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('Failed to get the pubkey') + return null + } + + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + + const handleBlock = async () => { + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Blog reference is already in user's mute list`) + return null + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + + const handleUnblock = async () => { + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + if (!muteListEvent) { + toast.error(`Couldn't get user's mute list event from relays`) + return null + } + + const tags = muteListEvent.tags + const unsignedEvent: UnsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + const handleAddNSFW = async () => { + const filter: NDKFilter = { + kinds: [kinds.Curationsets], + authors: [hexPubkey], + '#d': ['nsfw'] + } + + const nsfwListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (nsfwListEvent) { + const tags = nsfwListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Blog reference is already in user's nsfw list`) + return null + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: kinds.Curationsets, + content: nsfwListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Curationsets, + content: '', + created_at: now(), + tags: [ + ['a', aTag], + ['d', 'nsfw'] + ] + } + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's nsfw list") + } + return null + } + const handleRemoveNSFW = async () => { + const filter: NDKFilter = { + kinds: [kinds.Curationsets], + authors: [hexPubkey], + '#d': ['nsfw'] + } + + const nsfwListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + if (!nsfwListEvent) { + toast.error(`Couldn't get nsfw list event from relays`) + return null + } + + const tags = nsfwListEvent.tags + + const unsignedEvent: UnsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: kinds.Curationsets, + content: nsfwListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's nsfw list") + } + return null + } + + const requestData = (await request.json()) as { + intent: 'nsfw' | 'block' + value: boolean + } + + switch (requestData.intent) { + case 'block': + await (requestData.value ? handleBlock() : handleUnblock()) + break + + case 'nsfw': + if (!isAdmin) { + log(true, LogType.Error, 'Unable to update NSFW list. No permission') + return null + } + await (requestData.value ? handleAddNSFW() : handleRemoveNSFW()) + break + + default: + log(true, LogType.Error, 'Missing intent for blog action') + break + } + + return null + } diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 7fb5814..56d2b30 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,5 +1,10 @@ import { useState } from 'react' -import { useLoaderData } from 'react-router-dom' +import { + useLoaderData, + Link as ReactRouterLink, + useNavigation, + useSubmit +} from 'react-router-dom' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' @@ -14,9 +19,19 @@ import placeholder from '../../assets/img/DEGMods Placeholder Img.png' import { PublishDetails } from 'components/Internal/PublishDetails' import { Interactions } from 'components/Internal/Interactions' import { BlogCard } from 'components/BlogCard' +import { copyTextToClipboard } from 'utils' +import { toast } from 'react-toastify' +import { useAppSelector, useBodyScrollDisable } from 'hooks' +import { ReportPopup } from './report' export const BlogPage = () => { - const { blog, latest } = useLoaderData() as BlogPageLoaderResult + const { blog, latest, isAddedToNSFW, isBlocked } = + useLoaderData() as BlogPageLoaderResult + const userState = useAppSelector((state) => state.user) + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + const navigation = useNavigation() const [commentCount, setCommentCount] = useState(0) const html = marked.parse(blog?.content || '', { async: false }) const sanitized = DOMPurify.sanitize(html) @@ -38,6 +53,42 @@ export const BlogPage = () => { [sanitized] ) + const [showReportPopUp, setShowReportPopUp] = useState(false) + useBodyScrollDisable(showReportPopUp) + + const submit = useSubmit() + const handleBlock = () => { + if (navigation.state === 'idle') { + submit( + { + intent: 'block', + value: !isBlocked, + target: blog?.aTag || '' + }, + { + method: 'post', + encType: 'application/json' + } + ) + } + } + + const handleNSFW = () => { + if (navigation.state === 'idle') { + submit( + { + intent: 'nsfw', + value: !isAddedToNSFW, + target: blog?.aTag || '' + }, + { + method: 'post', + encType: 'application/json' + } + ) + } + } + return (
@@ -46,90 +97,223 @@ export const BlogPage = () => { {!blog ? ( ) : ( -
-
- {/* */} -
-
-
-
-

- {blog.title} -

-
-
- -
-
- {blog.nsfw && ( -
-

NSFW

-
- )} - {blog.tTags && - blog.tTags.map((t) => ( - - {t} + <> +
+ - - - {!!latest.length && ( -
-
-

- Latest blog posts -

-
- {latest.map((b) => ( - - ))} +
+
+
+

+ {blog.title} +

+
+
+ +
+
+ {blog.nsfw && ( +
+

NSFW

+
+ )} + {blog.tTags && + blog.tTags.map((t) => ( + + {t} + + ))}
- )} -
- + + {!!latest.length && ( +
+
+

+ Latest blog posts +

+
+ {latest.map((b) => ( + + ))} +
+
+
+ )} +
+ +
-
+ {navigation.state !== 'idle' && ( + + )} + {showReportPopUp && ( + setShowReportPopUp(false)} /> + )} + )} {!!blog?.author && }
diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index e74c68e..aadddb0 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -4,6 +4,7 @@ import { kinds, nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes } from 'routes' +import { store } from 'store' import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' import { DEFAULT_FILTER_OPTIONS, @@ -33,6 +34,10 @@ export const blogRouteLoader = log(true, LogType.Error, 'Unable to create filter from blog naddr.') return redirect(appRoutes.blogs) } + // Update kinds to make sure we fetch correct event kind + filter.kinds = [kinds.LongFormArticle] + + const userState = store.getState().user // Get the blog filter options for latest blogs const filterOptions = JSON.parse( @@ -59,15 +64,19 @@ export const blogRouteLoader = ] } - // Parallel fetch blog event and latest events + // Parallel fetch blog event, latest events, mute, and nsfw lists in parallel const settled = await Promise.allSettled([ ndkContext.fetchEvent(filter), - ndkContext.fetchEvents(latestModsFilter) + ndkContext.fetchEvents(latestModsFilter), + ndkContext.getMuteLists(userState?.user?.pubkey as string), + ndkContext.getNSFWList() ]) const result: BlogPageLoaderResult = { blog: undefined, - latest: [] + latest: [], + isAddedToNSFW: false, + isBlocked: false } // Check the blog event result @@ -101,6 +110,59 @@ export const blogRouteLoader = ) } + const muteList = settled[2] + if (muteList.status === 'fulfilled' && muteList.value) { + if (muteList && muteList.value) { + if (result.blog && result.blog.aTag) { + if ( + muteList.value.admin.replaceableEvents.includes( + result.blog.aTag + ) || + muteList.value.user.replaceableEvents.includes(result.blog.aTag) + ) { + result.isBlocked = true + } + } + } + } else if (muteList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching mute list', muteList.reason) + } + + const nsfwList = settled[3] + if (nsfwList.status === 'fulfilled' && nsfwList.value) { + // Check if the blog is marked as NSFW + // Mark it as NSFW only if it's missing the tag + if (result.blog) { + const isMissingNsfwTag = + !result.blog.nsfw && + result.blog.aTag && + nsfwList.value.includes(result.blog.aTag) + + if (isMissingNsfwTag) { + result.blog.nsfw = true + } + + if (result.blog.aTag && nsfwList.value.includes(result.blog.aTag)) { + result.isAddedToNSFW = true + } + } + + // Check if the the latest blogs too + result.latest = result.latest.map((b) => { + if (b) { + const isMissingNsfwTag = + !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) + + if (isMissingNsfwTag) { + b.nsfw = true + } + } + return b + }) + } else if (nsfwList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) + } + return result } catch (error) { log( diff --git a/src/pages/blog/report.tsx b/src/pages/blog/report.tsx new file mode 100644 index 0000000..b3f6f14 --- /dev/null +++ b/src/pages/blog/report.tsx @@ -0,0 +1,92 @@ +import { useFetcher } from 'react-router-dom' +import { CheckboxFieldUncontrolled } from 'components/Inputs' +import { useEffect } from 'react' + +type ReportPopupProps = { + handleClose: () => void +} + +const BLOG_REPORT_REASONS = [ + { label: 'Actually CP', key: 'actuallyCP' }, + { label: 'Spam', key: 'spam' }, + { label: 'Scam', key: 'scam' }, + { label: 'Malware', key: 'malware' }, + { label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' }, + { label: 'Other', key: 'otherReason' } +] + +export const ReportPopup = ({ handleClose }: ReportPopupProps) => { + const fetcher = useFetcher() + + // Close automatically if action succeeds + useEffect(() => { + if (fetcher.data) { + const { isSent } = fetcher.data + console.log(fetcher.data) + if (isSent) { + handleClose() + } + } + }, [fetcher, handleClose]) + + return ( + <> +
+
+
+
+
+
+

Report Post

+
+
+ + + +
+
+
+ +
+ + {BLOG_REPORT_REASONS.map((r) => ( + + ))} +
+ +
+
+
+
+
+
+ + ) +} diff --git a/src/pages/blog/reportAction.ts b/src/pages/blog/reportAction.ts new file mode 100644 index 0000000..c39e593 --- /dev/null +++ b/src/pages/blog/reportAction.ts @@ -0,0 +1,146 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs } from 'react-router-dom' +import { toast } from 'react-toastify' +import { store } from 'store' +import { UserRelaysType } from 'types' +import { + log, + LogType, + now, + npubToHex, + parseFormData, + sendDMUsingRandomKey, + signAndPublish +} from 'utils' + +export const blogReportRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const requestData = await request.formData() + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return false + } + + // Decode author from naddr + let aTag: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { identifier, kind, pubkey } = decoded.data + aTag = `${kind}:${pubkey}:${identifier}` + } catch (error) { + log(true, LogType.Error, 'Failed to decode naddr') + return false + } + + if (!aTag) { + log(true, LogType.Error, 'Missing #a Tag') + return false + } + + const userState = store.getState().user + let hexPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } + + const reportingNpub = import.meta.env.VITE_REPORTING_NPUB + const reportingPubkey = npubToHex(reportingNpub) + + // Parse the the data + const formSubmit = parseFormData(requestData) + + const selectedOptionsCount = Object.values(formSubmit).filter( + (checked) => checked === 'on' + ).length + if (selectedOptionsCount === 0) { + toast.error('At least one option should be checked!') + return false + } + + if (reportingPubkey === hexPubkey) { + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + if (muteListEvent) { + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + if (alreadyExists) { + toast.warn(`Blog reference is already in user's mute list`) + return false + } + tags.push(['a', aTag]) + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + try { + hexPubkey = await window.nostr?.getPublicKey() + } catch (error) { + log( + true, + LogType.Error, + 'Could not get pubkey for reporting blog!', + error + ) + toast.error('Could not get pubkey for reporting blog!') + return false + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + return { isSent: isUpdated } + } else { + const href = window.location.href + let message = `I'd like to report ${href} due to following reasons:\n` + Object.entries(formSubmit).forEach(([key, value]) => { + if (value === 'on') { + message += `* ${key}\n` + } + }) + try { + const isSent = await sendDMUsingRandomKey( + message, + reportingPubkey!, + ndkContext.ndk, + ndkContext.publish + ) + return { isSent: isSent } + } catch (error) { + log(true, LogType.Error, 'Failed to send a blog report', error) + return false + } + } + } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5532e22..4b6c7d2 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -381,7 +381,7 @@ const DisplayLatestBlogs = () => { } const results = await Promise.allSettled([ - fetchEvents(filter), + fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), fetchEvents(latestFilter) ]) diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 6f1c305..584c34f 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -23,6 +23,7 @@ import { BlogCardDetails, FilterOptions, ModDetails, + ModeratedFilter, NSFWFilter, SortBy, UserRelaysType @@ -687,7 +688,8 @@ const ReportUserPopup = ({ } const ProfileTabBlogs = () => { - const { profile } = useLoaderData() as ProfilePageLoaderResult + const { profile, muteLists, nsfwList } = + useLoaderData() as ProfilePageLoaderResult const { fetchEvents } = useNDKContext() const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) const [isLoading, setIsLoading] = useState(true) @@ -779,20 +781,67 @@ const ProfileTabBlogs = () => { } }, [blogfilter, blogs, fetchEvents, isLoading]) - const sortedBlogs = useMemo(() => { - const sorted = blogs || [] + const userState = useAppSelector((state) => state.user) + const moderatedAndSortedBlogs = useMemo(() => { + let _blogs = blogs || [] + const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + const isOwner = + userState.user?.pubkey && userState.user.pubkey === profile?.pubkey + const isUnmoderatedFully = + filterOptions.moderated === ModeratedFilter.Unmoderated_Fully + + // Add nsfw tag to blogs included in nsfwList + if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) { + _blogs = _blogs.map((b) => { + return !b.nsfw && b.aTag && nsfwList.includes(b.aTag) + ? { ...b, nsfw: true } + : b + }) + } + + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" + // Allow "Unmoderated Fully" when author visits own profile + if (!((isAdmin || isOwner) && isUnmoderatedFully)) { + _blogs = _blogs.filter( + (b) => + !muteLists.admin.authors.includes(b.author!) && + !muteLists.admin.replaceableEvents.includes(b.aTag!) + ) + } + + if (filterOptions.moderated === ModeratedFilter.Moderated) { + _blogs = _blogs.filter( + (b) => + !muteLists.user.authors.includes(b.author!) && + !muteLists.user.replaceableEvents.includes(b.aTag!) + ) + } + if (filterOptions.sort === SortBy.Latest) { - sorted.sort((a, b) => + _blogs.sort((a, b) => a.published_at && b.published_at ? b.published_at - a.published_at : 0 ) } else if (filterOptions.sort === SortBy.Oldest) { - sorted.sort((a, b) => + _blogs.sort((a, b) => a.published_at && b.published_at ? a.published_at - b.published_at : 0 ) } - return sorted - }, [blogs, filterOptions.sort]) + return _blogs + }, [ + blogs, + filterOptions.moderated, + filterOptions.nsfw, + filterOptions.sort, + muteLists.admin.authors, + muteLists.admin.replaceableEvents, + muteLists.user.authors, + muteLists.user.replaceableEvents, + nsfwList, + profile?.pubkey, + userState.user?.npub, + userState.user?.pubkey + ]) return ( <> @@ -801,7 +850,7 @@ const ProfileTabBlogs = () => {
- {sortedBlogs.map((b) => ( + {moderatedAndSortedBlogs.map((b) => ( ))}
diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts index 3c80254..aecb15d 100644 --- a/src/pages/profile/loader.ts +++ b/src/pages/profile/loader.ts @@ -1,23 +1,25 @@ -import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' import { nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { store } from 'store' -import { UserProfile, UserRelaysType } from 'types' +import { MuteLists, UserProfile } from 'types' import { log, LogType } from 'utils' export interface ProfilePageLoaderResult { profile: UserProfile isBlocked: boolean isOwnProfile: boolean + muteLists: { + admin: MuteLists + user: MuteLists + } + nsfwList: string[] } export const profileRouteLoader = (ndkContext: NDKContextType) => async ({ params }: LoaderFunctionArgs) => { - let profileRoute = appRoutes.home - // Try to decode nprofile parameter const { nprofile } = params let profilePubkey: string | undefined @@ -34,57 +36,94 @@ export const profileRouteLoader = // Get the current state const userState = store.getState().user - // Redirect route - // Redirect home if user is not logged in or profile naddr is missing - if (!profilePubkey && userState.auth && userState.user?.pubkey) { - // Redirect to user's profile is no profile is linked - const userHexKey = userState.user.pubkey as string - - if (userHexKey) { - profileRoute = getProfilePageRoute( - nip19.nprofileEncode({ - pubkey: userHexKey - }) - ) - } + // Check if current user is logged in + let userPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + userPubkey = userState.user.pubkey as string } + // Redirect if profile naddr is missing + // - home if user is not logged + let profileRoute = appRoutes.home + if (!profilePubkey && userPubkey) { + // - own profile + profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: userPubkey + }) + ) + } if (!profilePubkey) return redirect(profileRoute) + // Empty result const result: ProfilePageLoaderResult = { profile: {}, isBlocked: false, - isOwnProfile: false + isOwnProfile: false, + muteLists: { + admin: { + authors: [], + replaceableEvents: [] + }, + user: { + authors: [], + replaceableEvents: [] + } + }, + nsfwList: [] } - result.profile = await ndkContext.findMetadata(profilePubkey) - // Check if user the user is logged in if (userState.auth && userState.user?.pubkey) { result.isOwnProfile = userState.user.pubkey === profilePubkey + } - const userHexKey = userState.user.pubkey as string + const settled = await Promise.allSettled([ + ndkContext.findMetadata(profilePubkey), + ndkContext.getMuteLists(userPubkey), + ndkContext.getNSFWList() + ]) + + // Check the profile event result + const profileEventResult = settled[0] + if (profileEventResult.status === 'fulfilled' && profileEventResult.value) { + result.profile = profileEventResult.value + } else if (profileEventResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch profile.', + profileEventResult.reason + ) + } + + // Check the profile event result + const muteListResult = settled[1] + if (muteListResult.status === 'fulfilled' && muteListResult.value) { + result.muteLists = muteListResult.value // Check if user has blocked this profile - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [userHexKey] - } - const muteList = await ndkContext.fetchEventFromUserRelays( - muteListFilter, - userHexKey, - UserRelaysType.Write + result.isBlocked = result.muteLists.user.authors.includes(profilePubkey) + } else if (muteListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch mutelist.', + muteListResult.reason ) - if (muteList) { - // get a list of tags - const tags = muteList.tags - const blocked = - tags.findIndex( - (item) => item[0] === 'p' && item[1] === profilePubkey - ) !== -1 + } - result.isBlocked = blocked - } + // Check the profile event result + const nsfwListResult = settled[2] + if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) { + result.nsfwList = nsfwListResult.value + } else if (nsfwListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch mutelist.', + nsfwListResult.reason + ) } return result diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bab9915..8042e3f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -22,6 +22,8 @@ import { BlogsPage } from 'pages/blogs' import { blogsRouteLoader } from 'pages/blogs/loader' import { BlogPage } from 'pages/blog' import { blogRouteLoader } from 'pages/blog/loader' +import { blogRouteAction } from 'pages/blog/action' +import { blogReportRouteAction } from 'pages/blog/reportAction' export const appRoutes = { index: '/', @@ -33,6 +35,8 @@ export const appRoutes = { about: '/about', blogs: '/blog', blog: '/blog/:naddr', + blogEdit: '/blog/:naddr/edit', + blogReport_actionOnly: '/blog/:naddr/report', submitMod: '/submit-mod', editMod: '/edit-mod/:naddr', write: '/write', @@ -98,7 +102,18 @@ export const routerWithNdkContext = (context: NDKContextType) => { path: appRoutes.blog, element: , - loader: blogRouteLoader(context) + loader: blogRouteLoader(context), + action: blogRouteAction(context) + }, + { + path: appRoutes.blogEdit, + element: , + loader: blogRouteLoader(context), + action: writeRouteAction(context) + }, + { + path: appRoutes.blogReport_actionOnly, + action: blogReportRouteAction(context) }, { path: appRoutes.submitMod, diff --git a/src/types/blog.ts b/src/types/blog.ts index 6e5b8b8..673656c 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -31,4 +31,6 @@ export interface BlogCardDetails extends BlogDetails { export interface BlogPageLoaderResult { blog: Partial | undefined latest: Partial[] + isAddedToNSFW: boolean + isBlocked: boolean } From 2f563e1bfbbbf41041d7eb80e224f23005a1a86d Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 7 Nov 2024 18:05:19 +0100 Subject: [PATCH 17/19] feat(blog): initial editing --- src/components/Inputs.tsx | 2 +- src/pages/write/index.tsx | 75 +++++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index 036e538..38d48af 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -167,7 +167,7 @@ type MenuBarProps = { editor: Editor } -const MenuBar = ({ editor }: MenuBarProps) => { +export const MenuBar = ({ editor }: MenuBarProps) => { const setLink = () => { // Prompt the user to enter a URL let url = prompt('URL') diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index a525509..0cb1ebd 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -1,28 +1,57 @@ import { useState } from 'react' -import { Form, useActionData, useNavigation } from 'react-router-dom' +import { + Form, + useActionData, + useLoaderData, + useNavigation +} from 'react-router-dom' import { CheckboxFieldUncontrolled, - InputField, - InputFieldUncontrolled + InputError, + InputFieldUncontrolled, + MenuBar } from '../../components/Inputs' import { ProfileSection } from '../../components/ProfileSection' import { useAppSelector } from '../../hooks' -import { BlogFormErrors } from 'types' +import { BlogFormErrors, BlogPageLoaderResult } from 'types' import '../../styles/innerPage.css' import '../../styles/styles.css' import '../../styles/write.css' 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' export const WritePage = () => { const userState = useAppSelector((state) => state.user) + const data = useLoaderData() as BlogPageLoaderResult 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) - } + const blog = data?.blog + const title = data?.blog ? 'Edit blog post' : 'Submit a blog post' + const html = marked.parse(blog?.content || '', { async: false }) + const sanitized = DOMPurify.sanitize(html) + const [content, setContent] = useState(sanitized) + const editor = useEditor({ + content: content, + extensions: [ + StarterKit, + Link, + Image.configure({ + inline: true, + HTMLAttributes: { + class: 'IBMSMSMBSSPostImg' + } + }) + ], + onUpdate: ({ editor }) => { + setContent(editor.getHTML()) + } + }) return (
@@ -43,28 +72,34 @@ export const WritePage = () => { - - + {editor && ( +
+ +
+ + +
+ {typeof formErrors?.content !== 'undefined' && ( + + )} + +
+ )} { description='Separate each tag with a comma. (Example: tag1, tag2, tag3)' placeholder='Tags' name='tags' + defaultValue={blog?.tTags?.join(', ')} error={formErrors?.tags} />