From 3a440d547917350fd4244ede76abc48a3e634760 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Tue, 5 Nov 2024 11:51:57 +0000 Subject: [PATCH 01/51] Update src/styles/cardBlogs.css --- src/styles/cardBlogs.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/styles/cardBlogs.css b/src/styles/cardBlogs.css index 1991b68..849c88a 100644 --- a/src/styles/cardBlogs.css +++ b/src/styles/cardBlogs.css @@ -46,3 +46,9 @@ backdrop-filter: blur(5px); } +.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard.IBMSMSMBSSTagsTagNSFWCardAlt { + position: absolute; + top: 10px; + right: 10px; + bottom: unset; +} -- 2.34.1 From 7f0431f8f83c3f1fab2ddd2b10b37cd408d37276 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Tue, 5 Nov 2024 11:53:16 +0000 Subject: [PATCH 02/51] Update src/styles/cardBlogs.css --- src/styles/cardBlogs.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/styles/cardBlogs.css b/src/styles/cardBlogs.css index 849c88a..b075ef5 100644 --- a/src/styles/cardBlogs.css +++ b/src/styles/cardBlogs.css @@ -46,6 +46,17 @@ backdrop-filter: blur(5px); } +.cardBlogMainInsideTitle { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + -webkit-line-clamp: 2; + font-size: 20px; + line-height: 1.5; + color: rgba(255,255,255,0.75); + text-shadow: 0 0 8px rgba(0,0,0,0.25); +} + .IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard.IBMSMSMBSSTagsTagNSFWCardAlt { position: absolute; top: 10px; -- 2.34.1 From d4148ed01de94c56a3d59c94dc12a35d18412373 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 18:14:45 +0100 Subject: [PATCH 03/51] 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 +} -- 2.34.1 From c2413e1bd855daf5f702fab0f837e9791eb11790 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 20:14:06 +0100 Subject: [PATCH 04/51] 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(',') -- 2.34.1 From 3717c3bfb9a00b75a512e3c11bca51d57257bffa Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 31 Oct 2024 20:14:29 +0100 Subject: [PATCH 05/51] 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 -- 2.34.1 From 847aab29d7c0d2c3810fcd6c902f8cce75ea19b3 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 13:57:39 +0100 Subject: [PATCH 06/51] 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... } -- 2.34.1 From 73a7b1c1ee69ea556fc0562abfecb517e7845e25 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 14:11:11 +0100 Subject: [PATCH 07/51] 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 && ( + + )}
-- 2.34.1 From 169ab373043b91d6f36b6d33b689be9a01311469 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 14:42:22 +0100 Subject: [PATCH 08/51] 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 } -- 2.34.1 From a3bec707b0f377509fbb3350f695ad96c699ed99 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:22:08 +0100 Subject: [PATCH 09/51] 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 + +
+
+ ) +} -- 2.34.1 From 1d0f27d2557ae16326406185575a3f507aa26b04 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:43:08 +0100 Subject: [PATCH 10/51] 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) => ( + + ))} +
+
+
+ ) +} -- 2.34.1 From 2f32f400ddb6a0f3794bca2a59bc16fc92bce627 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 5 Nov 2024 16:44:07 +0100 Subject: [PATCH 11/51] 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' -- 2.34.1 From dae94733fa9376ef979f0c9981225eff041a8c62 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 11:13:42 +0100 Subject: [PATCH 12/51] 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 = () => {
+ ) } -- 2.34.1 From c81b2c0a1d55b9eeaca613323e692701cd969ea7 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:12:19 +0100 Subject: [PATCH 13/51] 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 -- 2.34.1 From 31ee0221b7664b7c006240b54a1ff97f5a6ad198 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:15:30 +0100 Subject: [PATCH 14/51] 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' -- 2.34.1 From f30ac01ea649296d92663ce753d4918914cab022 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 13:17:13 +0100 Subject: [PATCH 15/51] 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[] +} -- 2.34.1 From 31fd4ddfb5e5eb59fd8bfd77e3b3059fcaeeb9cd Mon Sep 17 00:00:00 2001 From: freakoverse Date: Wed, 6 Nov 2024 15:11:29 +0000 Subject: [PATCH 16/51] 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 -- 2.34.1 From 6d6ff8ce434ccd8a491bd0fb9b1ebcb4411009fb Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 6 Nov 2024 17:33:15 +0100 Subject: [PATCH 17/51] 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: , -- 2.34.1 From f734b1447f30977ad657a235559f900a25df13fd Mon Sep 17 00:00:00 2001 From: freakoverse Date: Wed, 6 Nov 2024 15:11:29 +0000 Subject: [PATCH 18/51] 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 -- 2.34.1 From f7f376468602c4d97ffc84685ec583b5f72b70a4 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 7 Nov 2024 17:33:59 +0100 Subject: [PATCH 19/51] 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 } -- 2.34.1 From 2f563e1bfbbbf41041d7eb80e224f23005a1a86d Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 7 Nov 2024 18:05:19 +0100 Subject: [PATCH 20/51] 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} />
Date: Tue, 12 Nov 2024 09:58:50 +0100 Subject: [PATCH 31/51] ci(env): add new env vars --- .gitea/workflows/release-production.yaml | 2 ++ .gitea/workflows/release-staging.yaml | 2 ++ .github/workflows/release-pages-production.yaml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index 981e1be..b2ba757 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -27,6 +27,8 @@ jobs: echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Create Build diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 10e4bc4..683d4d1 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -27,6 +27,8 @@ jobs: echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Create Build diff --git a/.github/workflows/release-pages-production.yaml b/.github/workflows/release-pages-production.yaml index a80541b..93cdf1e 100644 --- a/.github/workflows/release-pages-production.yaml +++ b/.github/workflows/release-pages-production.yaml @@ -35,6 +35,8 @@ jobs: echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Build run: npm run build -- 2.34.1 From 2c31c279a1e7d1edd3f10ebfaa73c413c6561f2b Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 12 Nov 2024 14:22:54 +0100 Subject: [PATCH 32/51] refactor(404): more generic error page --- src/pages/404.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 1c53760..d3e5a77 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,17 +1,27 @@ -import { Link } from 'react-router-dom' +import { Link, useRouteError } from 'react-router-dom' import { appRoutes } from 'routes' -export const NotFoundPage = () => { +interface NotFoundPageProps { + title: string + message: string +} + +export const NotFoundPage = ({ + title = 'Page not found', + message = "The page you're attempting to visit doesn't exist" +}: Partial) => { + const error = useRouteError() as Partial + return (
-

Page not found

+

{error?.title || title}

-

The page you're attempting to visit doesn't exist

+

{error?.message || message}

Date: Tue, 12 Nov 2024 14:23:58 +0100 Subject: [PATCH 33/51] refactor(blog): missing blog data will not trigger loading screen --- src/pages/blog/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 56d2b30..65b4466 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -94,9 +94,7 @@ export const BlogPage = () => {
- {!blog ? ( - - ) : ( + {blog && ( <>
-- 2.34.1 From 352179f1d9a361da2a86b2d92ffc95bfb3c446bf Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 12 Nov 2024 14:25:34 +0100 Subject: [PATCH 34/51] fix(blog): event fetch filter, editing as non-author, add errors --- src/pages/blog/loader.ts | 66 +++++++++++++++++++++++++--------------- src/routes/index.tsx | 6 ++-- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index aadddb0..27325be 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -1,8 +1,7 @@ -import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' +import { 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 { store } from 'store' import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' @@ -16,28 +15,43 @@ import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog' export const blogRouteLoader = (ndkContext: NDKContextType) => - async ({ params }: LoaderFunctionArgs) => { + async ({ params, request }: LoaderFunctionArgs) => { const { naddr } = params if (!naddr) { log(true, LogType.Error, 'Required naddr.') return redirect(appRoutes.blogs) } - // Decode author from naddr - const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { pubkey } = decoded.data + // Decode author and identifier from naddr + let pubkey: string | undefined + let identifier: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + pubkey = decoded.data.pubkey + identifier = decoded.data.identifier + } catch (error) { + log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error) + throw new Error('Failed to fetch the blog. The address might be wrong') + } + + const userState = store.getState().user + const loggedInUserPubkey = userState?.user?.pubkey as string | undefined + + // Check if editing and the user is the original author + // Redirect if NOT + const url = new URL(request.url) + const isEditMode = url.pathname.endsWith('/edit') + if (isEditMode && loggedInUserPubkey !== pubkey) { + return redirect(appRoutes.blogs) + } 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) + // Set the filter for the main blog content + const filter = { + kinds: [kinds.LongFormArticle], + authors: [pubkey], + '#d': [identifier] } - // 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( @@ -68,7 +82,7 @@ export const blogRouteLoader = const settled = await Promise.allSettled([ ndkContext.fetchEvent(filter), ndkContext.fetchEvents(latestModsFilter), - ndkContext.getMuteLists(userState?.user?.pubkey as string), + ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users ndkContext.getNSFWList() ]) @@ -93,6 +107,12 @@ export const blogRouteLoader = ) } + // Throw an error if we are missing the main blog result + // Handle it with the react-router's errorComponent + if (!result.blog) { + throw new Error('We are unable to find the blog on the relays') + } + // Check the lateast blog events const fetchEventsResult = settled[1] if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { @@ -165,13 +185,11 @@ export const blogRouteLoader = return result } 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 redirect(appRoutes.blogs) + let message = 'An error occurred in fetching blog details from relays' + log(true, LogType.Error, message, error) + if (error instanceof Error) { + message = error.message + throw new Error(message) + } } } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5c14fd0..aec86c6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -103,13 +103,15 @@ export const routerWithNdkContext = (context: NDKContextType) => path: appRoutes.blog, element: , loader: blogRouteLoader(context), - action: blogRouteAction(context) + action: blogRouteAction(context), + errorElement: }, { path: appRoutes.blogEdit, element: , loader: blogRouteLoader(context), - action: writeRouteAction(context) + action: writeRouteAction(context), + errorElement: }, { path: appRoutes.blogReport_actionOnly, -- 2.34.1 From b49ae9537b1639b71563cc2366a692f02dfd3c78 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 12 Nov 2024 20:15:27 +0100 Subject: [PATCH 35/51] fix(blog): nsfw filtering, use L tag instead nsfw --- src/pages/blog/loader.ts | 24 ++++++++------ src/pages/home.tsx | 62 +++++++++++++++++-------------------- src/pages/profile/index.tsx | 17 +++++----- src/pages/write/action.tsx | 27 +++++++++------- src/utils/blog.ts | 6 ++-- 5 files changed, 72 insertions(+), 64 deletions(-) diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index 27325be..e711c65 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -1,4 +1,5 @@ import { NDKFilter } from '@nostr-dev-kit/ndk' +import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { NDKContextType } from 'contexts/NDKContext' import { kinds, nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' @@ -59,33 +60,33 @@ export const blogRouteLoader = ) as FilterOptions // Fetch 4 in case the current blog is included in the latest - const latestModsFilter: NDKFilter = { + const latestFilter: NDKFilter = { authors: [pubkey], kinds: [kinds.LongFormArticle], limit: 4 } // Add source filter if (filterOptions.source === window.location.host) { - latestModsFilter['#r'] = [filterOptions.source] + latestFilter['#r'] = [filterOptions.source] } // Filter by NSFW tag + // NSFWFilter.Only_NSFW -> fetch with content-warning label // 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() - ] + // NSFWFilter.Hide_NSFW -> up the limit and filter after fetch + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + latestFilter['#L'] = ['content-warning'] + } else if (filterOptions.nsfw === NSFWFilter.Hide_NSFW) { + // Up the limit in case we fetch multiple NSFW blogs + latestFilter.limit = PROFILE_BLOG_FILTER_LIMIT } // 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(latestFilter), ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users ndkContext.getNSFWList() ]) - const result: BlogPageLoaderResult = { blog: undefined, latest: [], @@ -120,6 +121,9 @@ export const blogRouteLoader = result.latest = fetchEventsResult.value .map(extractBlogCardDetails) .filter((b) => b.id !== result.blog?.id) // Filter out current blog if present + .filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) // Filter out the NSFW if selected .slice(0, 3) // Take only three } else if (fetchEventsResult.status === 'rejected') { log( diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 4b6c7d2..072cfc3 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -6,7 +6,7 @@ import { Swiper, SwiperSlide } from 'swiper/react' import { BlogCard } from '../components/BlogCard' import { GameCard } from '../components/GameCard' import { ModCard } from '../components/ModCard' -import { LANDING_PAGE_DATA } from '../constants' +import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants' import { useDidMount, useGames, @@ -31,11 +31,7 @@ import '../styles/SimpleSlider.css' import '../styles/styles.css' // Import Swiper styles -import { - filterForEventsTaggingId, - NDKEvent, - NDKFilter -} from '@nostr-dev-kit/ndk' +import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' @@ -332,38 +328,34 @@ const DisplayLatestBlogs = () => { // 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[] = [] + const filter: NDKFilter = { + kinds: [kinds.LongFormArticle], + authors: [], + '#d': [] + } 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) + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { pubkey, identifier } = decoded.data + if (!filter.authors?.includes(pubkey)) { + filter.authors?.push(pubkey) + } + if (!filter.authors?.includes(identifier)) { + filter['#d']?.push(identifier) } } 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 - ) + // 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) + // We fetch more posts in case of duplicates (from featured) const latestFilter: NDKFilter = { authors: blogHexkeys, kinds: [kinds.LongFormArticle], @@ -371,17 +363,15 @@ const DisplayLatestBlogs = () => { } // 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() - ] + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + latestFilter['#L'] = ['content-warning'] + } else if (filterOptions.nsfw === NSFWFilter.Hide_NSFW) { + // Up the limit in case we fetch multiple NSFW blogs + latestFilter.limit = PROFILE_BLOG_FILTER_LIMIT } const results = await Promise.allSettled([ - fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), + fetchEvents(filter), fetchEvents(latestFilter) ]) @@ -403,9 +393,13 @@ const DisplayLatestBlogs = () => { }, new Map()) .values() ) - const latest = unique.slice(0, 4) + .map(extractBlogCardDetails) + .filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) - setBlogs(latest.map(extractBlogCardDetails)) + const latest = unique.slice(0, 4) + setBlogs(latest) } catch (error) { log( true, diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 584c34f..3b6ec70 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -704,10 +704,8 @@ const ProfileTabBlogs = () => { filter['#r'] = [host] } - if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { - filter['#nsfw'] = [ - (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() - ] + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + filter['#L'] = ['content-warning'] } return filter @@ -725,7 +723,7 @@ const ProfileTabBlogs = () => { } fetchEvents(filter) .then((events) => { - setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) }) .finally(() => { @@ -752,7 +750,7 @@ const ProfileTabBlogs = () => { setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) setPage((prev) => prev + 1) setBlogs( - nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) + nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr) ) }) .finally(() => setIsLoading(false)) @@ -775,7 +773,7 @@ const ProfileTabBlogs = () => { .then((events) => { setHasMore(true) setPage((prev) => prev - 1) - setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) }) .finally(() => setIsLoading(false)) } @@ -799,6 +797,11 @@ const ProfileTabBlogs = () => { }) } + // Filter nsfw (Hide_NSFW option) + _blogs = _blogs.filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + // 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)) { diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx index 447a605..61bb7ad 100644 --- a/src/pages/write/action.tsx +++ b/src/pages/write/action.tsx @@ -84,22 +84,27 @@ export const writeRouteAction = .split(',') .map((t) => ['t', t]) + const tags = [ + ['d', uuid], + ['a', aTag], + ['r', rTag], + ['published_at', published_at.toString()], + ['title', formSubmit.title!], + ['image', formSubmit.image!], + ['summary', formSubmit.summary!], + ...tTags + ] + + // Add NSFW tag, L label namespace standardized tag + // https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags + if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning']) + const unsignedEvent: UnsignedEvent = { kind: kinds.LongFormArticle, created_at: currentTimeStamp, pubkey: hexPubkey, content: content, - tags: [ - ['d', uuid], - ['a', aTag], - ['r', rTag], - ['published_at', published_at.toString()], - ['title', formSubmit.title!], - ['image', formSubmit.image!], - ['summary', formSubmit.summary!], - ['nsfw', (formSubmit.nsfw === 'on').toString()], - ...tTags - ] + tags: tags } try { diff --git a/src/utils/blog.ts b/src/utils/blog.ts index df11b63..306620d 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -8,8 +8,10 @@ export const extractBlogDetails = (event: NDKEvent): Partial => ({ content: event.content, summary: getFirstTagValue(event, 'summary'), image: getFirstTagValue(event, 'image'), - nsfw: getFirstTagValue(event, 'nsfw') === 'true', - + // Check L label namespace for content warning or nsfw (backwards compatibility) + nsfw: + getFirstTagValue(event, 'L') === 'content-warning' || + getFirstTagValue(event, 'nsfw') === 'true', id: event.id, author: event.pubkey, published_at: getFirstTagValueAsInt(event, 'published_at'), -- 2.34.1 From 0c7e61cadd7c16eedb3dd05739bf6ac523df0365 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Wed, 13 Nov 2024 00:21:43 +0000 Subject: [PATCH 36/51] Upload files to "src/assets/img" --- src/assets/img/DEGM Thumb.png | Bin 283420 -> 321678 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/assets/img/DEGM Thumb.png b/src/assets/img/DEGM Thumb.png index 763a04befd74e7082e938c9e10d16c9a0bfa856f..6dd67f7548a1e40b1e5f202cfed04320649326c1 100644 GIT binary patch literal 321678 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

&Cw$`OlEtlAufH-aEwj){*g-l?Wb$DcJizx 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 z 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%0AtePRCZ<{A zh|Ab1b|QXwCE$i+%btqUz! z(HeqfK}B5URny&?8xs`M|Ek0<*#4^xq7B>k3=}Y(E)-6`2xGoUa)NL)*a5}V#G4Hc z+a(x)M;wR8`VZg7wfR2Of2I|;k%q+ufJQhdG@CC=BLU%mo=f;Fr^Y+I0VPf^vmr&Z z{4L&PFXR)}SzjngUlIpzhSdx2WMcY@NKLb*G$8rO?IQp$bniq(E>T7rWfFdGWADDo@wE(b#~f!nf=DcVAuLxd zv1%)huck@HFq%s$fz&IyUn0%#@8-*|2l5}|+In6{9Mw91zdSzLUq>w#@TREH@L|a- z?xB~Fv6R-@HcI)&?}yOv^G<>r-qejUf{=B5T~8h0_S8>tyRc;aKj&;ra<_L za#|F>s~g~FnaUQJj)EhB5o}mrYjFUuWd}jwKaWTNb+g5Fo4~7ojQ0(2=)0*-%lj#} zm6twEz_bS&B|W3Zb;KtG+8!~_2556ei%)qi@Kg1ad`lB+`e!X-85CHgP~V|mr( zd$oKcjpuIz#=$jIZkjC5B*Jp6$=k$l!m)Mb5c&Dk--pAeGM0vtJ8g8@4M=Fsnr2f* zQ5$4sS=n)g2?Df5gi z$!`5dZU#)T5=p`sm%&Php~Mkd#F0DDbeNrq68cG(&_!etIimUki~3$fx2sxiE-&?T z+4XoLsZ{$h+5xFgBccTq24#=YgtPO6nEv$)JN=S{kGmtnEiH2A1mucKEQEnfLmlcD zI=Y_OC_RT;&l++tldg(zJ9#*W+nmvu&2D_hvi9>t1JR}0vktDjqwl? z%Q8Z_`edYU=zOM^1vJp@AUHldNk_HpR@d?VW>Z=DDIMgZ1`4!_Y*1O0&*m^7FZ@r~ zNy!Cx%6|$UX{%c!TW(h*OJ;^?%rm;XAwWlD7?q?TpsG?tm=82)xj=++3w!Tx2DIFj zVOSE{i>#nAAw_}LV;%KIFFbbLubV2|7tu_MdfPocYJD-S>rp! zI2~aa$(m>Fv^2WvXrlm z+wbp2;{ve4hA>PrH8u&!3u0K=b)k?yl4okI`3WNqXKRgMBh+$wQY0=FsSw2y>BBo@#{_m?cwY@*=u>NQx;k7vKZm!-I$79R_tg^c9&bx`IVRU0e)|qy z6whfE7(7Lncw=c1TJQVe(`dW=7fAJ%^IfMFdjBI-C&m)lw&u&_fE$K4E;+IT$yzXY z@INvM&>tcYCyZS6qs!OW_kc}sUD9zsx{``6U#bqW69R?a@hebh2jn24nU9h$(V%f9 z0b{2sJZDxsV2c*W4MC$6Q*$80tzVy{b~LUXSFHCni)&Ci3!O}$Jw16^T$WiT^gGf~ zsWs~?_{|wD%q>h{W?ck-8)d}j@&f?@`<=P+gp66gWl1ej!ws}5CC}HHqnHry@HM02 zLCX?R%HJkLvJkrFJih)EhVVzx-H{SvXIcO3bxawAVv$8;V@<&&$SKELaF_3KT4t>g z`9upgljj3n@XNpOXn`>Qezfr711PKtt={(i+pBo+*M|~;{SU8K>Gn(Soc#sYcf!l} z@|fFFd9Sc9A6G*JXJ;nwuPt=i_3_b-Sb>Km=#sYHBdQ?QYxTIJb^piJTLsnCby1rM zE(dpacZcBa?t$PMTtaZ?;O_2DaCdhn2@ouJaChs?`}N=5)$4*PxT0$9Wpj@43|dQ6 z?R6a1a8{j;`|@?6Zt}nRLVXdz`bpQ}k{K5QsdGD|%~oqs=Kr+^0hUa73n|&T1%4T{}3K(+JqWAWlYb zOm(O6L%Pzl7=jFH**nA4J~+fhJMO#7HqPhvbFy1g+u!Bo)poQh==lALAPexri@5Ms zJJyfR{a-w!%KW)Hyb>mPH3I9bd0jukM+=tk0qNn!n-j{NP zg-Cu}0jau3iY^keWlp>QQBAAAcFzudN{-W~BJ`}TTMcG|t|;jifj*B)=KkgZcl&B7 zLgu|@dbSiP-v{)WZ|Q5C1iKGK^>};DZls`~tW1{mf~`0FuhooPOGeiPdrdGCRvE|P$M3`>PJUigD+x}YJI>}ue7qV#O%&!S((-Sdk>+tVwi zffW}{)*E|u&*LWp?*nuF?+;v^my$I*zZSa#QolTUVo!F2h34Sxi5tFRTXsFJb#6Nd zPk?`P{O!DY8024vyFdQany$jDMj;bj^IG=RAal#=FldI#r{n=Xw4!-ycEyYY|a^)hDE6TJH*WztpzLQ8Zgnm1?H1qi|i zZxA$Y|sY9nsnJpiH<8u!>{Js1Gh=B}W-xX>ufcd8o z&;xLe_z%ga08LoaX+Zo6DK7q>B7YK9N=m8~piHH6RVEV{I0^FKo%UB&Y|_vVG6tOC zjQ8v1IjeYr)zBvpwjVs|ftNwVw(F`+G7(%wp*p-nvgZcLSUBJ3X-k%c0yjqs&**N`?aFF#W!-7}hp&P2Nri@iqskA&i6@EsF*P zAF@j5LWA`E8)~O;#mjwU1fXC-6UybDEHYrSAv59l*g(e0EE`QF9Zn}&icQhUEJ?v( zM8%{W&$r9Ms5dYV7dQ@++Z);;b(pD63QZzH#gz&;cd(fncgD9H)U~9>)_&hrIGl%-0QN-!a{m z&eYiMJGW}Tx#G_IEf)`y7f7>9iY2!qauV;YXm?-ozhuLN&`_e5`>xc{HI!0y)wvme zeYN~qe8`n3+QiToC$M@fv5d8N$-it+9R^!RHsu!$0x8`Gn~%)hNxc2}bn~N~DQy6^ z^TG#ZfisAjL`^f&hfBHA<&2?c-&BS&28-H+Yg0BgWe?l!cr{?Zz3Ow*VcAK(luPG( zvsfJ#po`4B7q)91Hv`}M?pdz-PB*{aE2~%k#8LjYst4}x{NBEQSbt1)pagWqiZp<* z7FUYy!chT=lO8&Mh-0m1snUn?Fm8a3NyRZM((gw*In3u={Sxl4%> zRG(u=S^3M;h zJ8=A zD>ja-q^$uV%L9zI6VzwBs`WZJ4Hg5#@?MD>(!1M3d>~^~#1?WYP9B~*zYXBoyE=Bc zI}t8b7`jRY_~75=Q;I}OI*l>8$N#qqaxP1Q@i+|+NyJr=C>r<_JdW!|mU{ezZ5DI- zCJZms+)mab&n)7*V^Q~@ejR)rO0V0Im}o?sCq47Hjyk5;H)loi>U{kUC&z)`pZ9l9 zb{{N?z{nAd&hAJi7h(sNnZWdOKlRm`vHlyx(;X$z?a$EbY-_thjS0UUJg~IM%I|i( z&*k9D<>xfzQMGZ7=|+oeQYG|RHUe+aEFh2Cg!iYy`b5I$H4O4d1?EJA~~U^=Xl^E?2ds9*Y%1zhE(wjB^)gK9apX)}7%IzD@1iN@g*CT30I4%WI>X zu5VB_e;EeE-zg{;r^>5AoXY#Vi`{9G4TWa)1Ma^0NhkO}!>SxvVDKG7hN4Zn_zyPQ zp%o|w$3rT4-;QnWuWo~$pVvcm1pJ6QKIKxV@1lo@^PHzmLi3J!jEq75dm#QX)u1w8 z?9iGShapMF#a!P@RSCDkrq83yojS;Q0>AZxw@4!{!Y0((!OGNa+x8E>1sbAc?m`=l+LIAKpl8pF(ien@MfROxey+S^W_&rO1c-_105rcT}MsA6dG73a^ z>{pV`VN*ba;w_2BjEFZQX(mEAgh_!(7I5C4l>0>vMujkxQ+q(Gw?~+W`m3V~{CD)V zjIg%%Hw!(MqO>za4!%U-lQ`O)Ix*xDb0AY)i8mMvc{^Ba2T@3z!lV1KxM%lznR~`| z1t4b9(gnG0RQN{}Cri8SCoNRm-cK1`;+G0MbQTAVrmHdW20( z6f*Hp-jO!`tLK&>pA%kEe_j8*yFk;Ob+fHs1dHEa#phi;dyD;UNKE}ytO=Ys?$VGM zpFJgJuN%wdR;J>V zbh^DC(DV%p%Sqlv%ZGz3r`;XzCn|sjseZo)(_nEFL!JZ7llLw9n>1b9MOtE>-c=*z4va4`}12>BhZ3bXVUv z1)%_4g+y$sO^K*qNSsKB<&5A2uL}+0i3qgje#uP>be&v-Oy1i_5e)E zh<*dMrp8NEbSO)n7N{f*S+PVw0Io!Kd9{S}RcDCW=uboMhnpSykl{rb1ELwx9PJApDfm@ifzE||-*dRP3EV-RrLzDpfK z(i7^-<_J;n2>=)!&=@OeE#d-1l_RCXFV-JkPt{4tF~j3<6}Q?B3P@F-Dibr#%TXZ? zp3E-k$n)W<4ULQ%VOLxO^Chtg#bJeb?O!C9%m#(H5!yf}sCwuK8u0*g`- z?=LF9Vso`;J9M7>04g5yi82861Bkdb|GXrLHd}9}u+q;unYZo0JNer#yQ*vN^XqPN z=5o`Cs+y3@2I41+q~es%n+*SAj+ce2e7lX!a#}OIT!qQy>Ae3o`4yQh0iUt`UW0EaBSA;^xm-gQq$#5jhx_F>lMVDF zTR5W(2{+ubVxH|*os{!B?UN@DMkF`(evMo~j+s|ZY{B>IU%hotI&l-F)(*NN;;rFb zusmo8k|R)RY|YXEI*eoc>I(Y#u?S?fpSXP58F=-1c8gjH5_C!=M`O0-Q38zKUSB>i z8g#m6%mHf*=6`GNmIyG|0TTtFCEodHeAa33);B1t-wpq__R_r0x>At{xNE&1)L!=} z&P;9gl~fQ~;)YqHWNj9pmE{lW1A=aP_jY};JnJqp0cAZy4Htx^i((!I^GgE~{p=v_3x#KY-YO<(C_{iNXcuFLs zRw?>&*&yG~C6az>bN`|o$E!Zp=ZK7>8dv6LN$Ga@G$}CqDypEUM8TJ-(ts-w1nRfx zxb3~7W_qr$ytiH)jFl~Ux&xCfe{2$LJixQOO~lUP8w^z%xC*9=1?rPUl0jhAJsTF^ z%Z{D!+!`d4YpP#W&zDo9((Z0QL4%Bq@Yq%ZrPb9LU}2l%#-p_sAp|HB?N(j&{iT(m zG%EhY(gc%c%GbNUTAR5Py52a@;M0vGQmNx9Us$#{a(o^0N@MdyEoxyc-PbIB7g99|0h4~x%-Tqogj1I%niaa*6@Zv5dqV`VBE{<#UGlv?z2>qRx?W`8 zbtksD>@8?wzd?b?SR)T2o9&F#rBIWuAyq5>C1HR`)#E_OIQNH^4*LOM4 z`Eg7UG0;Wy{+E#^%B24AjABYza~i_S-sJya^vN>J^fNU|G8ezt{pB2z*@tR|o5Qv4 z`*uaEicXm>V$0_`UQ;HRhbacsKY8(321vLC$78aUFJ}C{VUTp4FdYl>`X9+E@wsrl zJx8Bioc27iPwZ!n>Dnk!9PM5Bu#_!o%iSfvW19R_*rBy#0-|L7B%SIDFzjUAZ+vi+ z_s)G%-K&yw0-(yP5G``5`xpPn63RPX<*5va157>pqTDuRS~0% zK3ru__iA~@pK{fIul}j`?16VxU$1I5#Klb!zYI|DHb;{|fl^3FpPf^Kz^LR>6O z@&4jm2C!-(sM9M<6j<^qu3PVwe)ow=-t;A2Ys6ys6LyAm{wz>`uTt+%9~lNQ5|cPp z3Y7WkP8{Rn7@(RlELXZAI}hqnOIfV<@_)PpGl~vclM>s5ph(IpzH6V%WTeS%Xd^(1 zit{QJu#V5coX@(C9r>*Oy&Fb`gIQw71s8< z+mp%cYdUAG)HI(O7Srk|Ay&(1YL?YK#_Tp42V8$~4xvPkmFWz(%g!#6dU8sN#WaSI zx{Dh}+FqIy|J};ZIkE^XV;&$(TLM92ZF6X3aAcCR?8X87h+TCYZ z*{PTuWu^PKnfLeC4Ae?GcMhxZq+_dBg8goU6RvQfwqP*$-@!p~SC`LllI2_$;eQ7q z1qazLJ8Iq(;O;3($WxN+W2qEk zYjEDOGKy?@wB?NjK#U+HGDppGwPu=GKn*m8fivQ0J*97J8}4MePkVA-US{S}mt^l| zEj+<3tZ_?<>&k@+vC&>{lYB&ILO1O^sK_I%MP>#r9MN-dbymSvoMOD_7SmPO^$y)wX;}TYlaG{1x(b&jgU6eP zi5?z02nk}Oh(Zl?HEvA%4BB+e5?qs=)`{CA}fGtN-ErsLi{ZFdQR+nF~@T?+)`!g_Xc3|0v4uqrc!S7+z0G(O~;OZHFAcc zX+cdm#?k*{WSD?39C9g+lS}^MXR7G0skzouZ>5PnFnmZGSoD_~4IJx8E^xk6xC7gA zv7_8Q-t#)8cd)u4KRmM_*oZaZ#>z@B53AG%zk?0rxgUwcC8U#SF@HiKaQEJ>2^;y$ z#`Y25%c~rgGckOHN|@>4=! z$0##SUQP_=`5+zX`RkR^smG=6UJKV%MW(^nup)mg6RZRqQ!8!_QB3VkFKH>GaFDR? ziD~CH;>K30@@qQPg!bGVCVW;DJ?bk2Y*BzQFK&rAb#8wm^MwvahZly9GmYQfFk-$j z^2~`}f<|?3m3xv-8Jb}`Z9ejkFUj*sG~PDHJE+KWj%hySqOW(#J;u73Be4#CH2Vfl z@K4f+t+=kPlLN^KJg-yxaOp^ly1b#AW`Su$(vKl^K3Wwzxz(*qeSOZXyVyj#cQz9A zSe#Vxzsa}{S<4SO)Ye>g{!JNT-p1`*S~K%)V?FQ1`|Th6=-KYnTvQ*$aqJSC zJE4h_86N8oMf=R^df8Xtc52;i=%Fu5vI1ue4{HeBB#05N`r-5E&*O@sfVPzAy(9f? zXET&!51^9*h&(n+W9gA7Sr4HceeY#+@7)-F;E%FXGrq4J#NIn||79|8W_+4mULG3} zx@8l3dziU;A@Pa4n(hodS#hiVdPASYf(E9I^h%~JM(rQuBoe6cKkG>AXNN zq>ew_7J2gwHb$>6fMbXg)tHl{mPn>T@UOLFOPF#ra!=Dw4gFJL1;^JYLC(p?%@hxZ zlp-lkJ2;$=_M6$T|DP35cpcE@yUS|eJ^5K|yV{+r(utM?H^?hO8?e1}d`M7ix{h%A zx@G7Bgl_kJ=~)>r{6bCSvd>;h7ZD&KCyYAIT06!1ppVTlKRW#7$w`{~GWpJMC_yq( z=9+qWjw4WHGfYMILsmxlQA;xY5MG*uBEdwRM};L%tiUb5f!nS3>xN-ZjIMl2U561& zv`TQ1#{~ocZ};0tSA1%_>~xVWl1AbxY@*@#ls z)QaK4ioe8oyRz<&yL9rr*!b5yHq?(~GSJLsVbU3GmsvpDBlHz%B*uBOLL2=$8Xmt8 z3L;MzZDCXT8%Rr)qi3+$)|Mfd+{%Q59UacUPUjfcK(~;u{e5?zBXluQ&2D#R1TZ+c zXb{eVeDQ~K*-npR8y|6vHa@<@Lcf!4H>HIi(cUqLH9jXc4;vfLIH@^L=(lIWy<1d~ zBDxy7zlM*+aKRzQ;By3c?;d~ik@#_OS`|6+BvNe|lML<{ET$aVak{eNM>Dx{6YZ#8 zq`ksg00tog74hb^D?4V@E#gNVDa?uAT3%Gdgl(E)%vxF;pR?|3?{PNbeyzP>y1eyp z%*X4Gfe>`2-I@zk@(~stPby04_4Y^0`Q;V&1qq7n9uPXR+}VI=9vTKv?ZvnuWIvt6 zE8NU;otrX$#@%uO?w|k}d)$bApm&xv^X_H=9SI0igbyJ%LtNfUj?mL=*x1hS(f}b` z(}yq-nv}@)X+6*E(Jc3DqY2%QN8V2oI8b6m%vnp+0T~6O3v7PKJ&zYP_m^)SnH*Zx zgEyD%2E+q!#&~1`EQZEm>Y9IaBsV_GK_fy2>0Jy|+hMAD&#)_c13n47tudyV&3)v{ z6r3;pI70W_d)Z#h6i@BAcn8oEd%b-IDOEzrES=lGI!{SnNnVi9@I&2LOHW(@c>$0Q zhTI&U4yJU|&}e{%I**_cgHu~J&e%H9cUeDby9+;?C(IP@H0Cxk!nII^6Szh|!8$cN zQ|5J%R}2wn>-gorIez zknSH4jCES=g-}04=eg7z0iqO9n*s8ECHXj$G@&r-KlcVX-eh=_Otkzzx3m(cyvr2WOv41LtSp%Jl5T0?J zuMGS5gRkASUy&SER>ph?+^!V%KRi8QM3n^ow7?6WZ#Tnc`DDZ9(7B0!4932_L9TW6 zBRr){rKK`qgGL_D)!gT3Ev7>a%kjA=r9L9dKxh>&P6araLgoF;D(^jNuxo zc>GO|+TYs}IjJPgE_InuDnf@_VdmBdSnq=M_@!qs zz@MW(!*k{Z{;fiTl8g->C@7)x51`3M2xGgHPDA~0%dTMT%dYp9ZQb7iMTwDcxp z@+33K?(_Rq>e?=Mxx`z0i(Nq^J{Q}ZG1&jqVNyQYQ%90vfe;^SqvvLePSg(DYEZVAR+Rjm)L1B%(+KR2ar8J!m=?zuGB4iE1-f@~lL;`g-!itsJXwgHX#cHCZ^2MXK zDCapX)!Q^ZK52qq1Q|0p5G9kcGEHdfBAbV?;Hcv2Ri15!v;1~1{jfA)Lrenk!4MP8 z7ezCVJ}b)|XKdJ)*D*p^$iL&wSSD}`M5^P=u1=*z2Cc%s zqap#JZ5YymlNVl^UVH9rk191m%;GHx%+Owq4us<$g|^#6{M<#OkXTA z0W#Jz_GXC${9m|Qc~3?vjtw#gWIz}QR#<4Jj=(M^j9IHQ7R6G{{M(hI^^10Jn<_vt}x^eem z=5;UDw*76!*8ASNS-2znW#ukj)-qg5TqortS!g4XiQyk}zhMfDuaxXw%olAgjv0FY zCa{*)e~EJ&_BJxwu8^fG)PFN1fs1TikXZ`s>$zck=}fz?!bV6}2uW9*TUi*nM4aBc@_X^T{eD786Z9v- zm_OnO33z!ZtSeI}z>-axSQ@Ijsm92wR%?U@2UzpT7}px#x%s~Qjg>*E+TV8Yp(5~m zv0J~oK#6n1dTKeQ);m`%VdHLro5m>#Cxe1*N1m^BhtKu6v!SJ2xGwg@#aoUv9jbWr zC+zt2{p%LA^4sx6X^Ie>=2wKR#A#67*SXy)eOKaTeU_X_;2)T9{5tO+YcAQ7agQum z`#gZ@*2Z0WmYVT_C{%|lqcGSFxVUAQD!I70{8!O>U(g-~fFq*be(jU*MWmtQ#F4!i zZeWl1ww(+-QRE(U0bzhXX>zP}2&)8?xw{MtoaazACizkbRYBP()c6*6ll+*E*wCPF<1}r^2W7J`nU;(3M>L_b zh5r24;Vp2n=lV0U>s?(-Y2>gZqLnT$2~Q>y&o*2JA?y=ds|$&P=l;>dz`)Gq+Qv&d z;*2240M3#dp*A(%*l{m+M%dESk`|Qw60Qa5Ak2~I(&lS_8p%=BYV_G$m-bJz)-tZujL@;eh;%7KRCnIGVmbM3&_1h(w*OM0I zIl^US%AXNf5!)W7{T7;M@#5=Ep-1T$SP=U^DN2Pzj8dym6`&zzrZy20x#X)${+28+ zK7o$DbJtp2aQO!una9TGaW7{rT)aS6t4P*$w9tB##mK5POSvfghb*)Ns7e2&py7@r zQNQ2Y-{+hUp4a`Hw8ndv;bD2kci6^WS6W;g(!uA&aQoC%x7h@~8Yj%378)ol&&&9| zp@wVFMD|bVSYR4@wE8m39B2lV8D)}rA8bEDCne#6MSoh01cQp$1KI7sMpkA#W0R!d zgTG!viw17cPq%0L2U7%Axg4Q7jNlqGRYr8p&oQWhRr_SBSss7rb5`3Em7nkZ?w<+l zrEZWC&nY*p;DDn`z|BEe#18<@0IzDMh74H{jj9+K2y!w3&Lpas8z<9>bGT5z*U{fc zP`pat87BH-&*YIsBf{U*Pb-o`PINR1qn}dypSk)QH?h~L>a+n{F3I!BM#I+wcJ8xv zY4YR(<5zDBCcUWK84AIk@qC)#k22`tly&j68?Tqr2M=#T{SLXFQNI+biuNH+O9~~3%|M`N1c}OB6aQ(7Ozsi@vz!VS+nx? z_bIMin9nNeAgKPpz!FZa_#D_Ao=>@~A8;*`5!l(6u#h~?{g*(^rT&x5}lfLsTcEU_V_2UM=__H7MCtgd1kA7q^HU0u{} zsvwOYTU<6OleUV&7LC39mB+wN6M(ry-eK+L0^nqLpGDxgwl^0_{N4`C0{C$4?^QMy zcZJ3a)0(Ip?eP%=@{P?x_xgCnz8>zTUhMM=4FoQ`c(^SZ z%r6VfEuS?0&3q@Lcbs{9;wOGS=(!At z_4U86;%?`*H-GE17breQ8{Ntnm&6;15Yg&}6T(>;=2UEN_p;J&3{&ntp=PIme{>E4 zA)O7@nKX59T@VvPnxMr3gY#8VEC8T1YTRaYbd`Wri3vr zZTq*wc$!$TT2nsoiT&$-uD3Lj>EuT%e$kU2v3%A8cc9TP&JV;Gyl?#otJqIs#21ygYaO~&$eFSWiv_W$_tc#sHFu=Zo zE9*wa7~7fqQL^r1SgiRHuU2qira9*?X`$TVEBS*a1gao>zwR?rTFl6N-Be)l)7rX?`!D zM_=X>)R?}UGCNm?nrFB_Kq%TNf^K3jR}2MuD>od8q7{)S6U%#{;gYbb3mz@>V3u72 zOSw-9+N_BckrXiz9@vH~yz^TxFXFITU!@YCmUGufAPI7c_-xMydVF|lye=YnXMyd8 zKIBZk|Kvv_qhVP82m|KuV2?9SO2V!}04po%@!4^HS5p9s@99hDZpqa1W_YwfiyaC- z%Jl+&R!L<~su_3eHKb)WYQWzG!Uo-j*64+rE`RxHgQ-YnzSM_GC1_=K_u5p~nP%rd zO&N?K&D1|MF}F2#!K4ri@MJ!*T^)B<3EvhkZcDkZnl=O^ZPTN(qvn=s{k_Rl72*Xu zVX&ot?-o*1-Pcxg&8Y^I{R3r9#iAoNIZn@iD_cu=fsEaJwAPyLN&%}7Ecc^|vK)o4 zg-QQ#kTt){p#zJ2>*`UvGX(a31DXH5-EjbF#zd#EL7bhmE6`q0*SAG8-Ai zs=r2d7)lfx*G>~7g_|dZoRTp5%w49)1OY3eoT3kV4`&oMg#ovPp8qWxp5K7gS|91u{nt zK8qbYf~lIqeal* z^%`fsnl2YSFzU6Zm3X-H)ykqEyBIy48llM9sA5@naVZUspI59igfK)j!60EUU>3*8 zva$2_m)bwQxtnY=b6+DFPp*UQB;kQjk2MX|uuo#5XSFkQPF4qJDhsq|owRLF^AE-* z`PkE6+%S|+x--}3m|A_oST5E1OwD0g{DC#AM$b_FUvDB}G#R$*ju6Pk_ZF=v5dgz0 zzuO{!MPvW3*EumGZz5HFn_M>bdo;pt$R~DJfR5|niB~HvE3;H1dW@5iks-?b&r%BDK!6d;O&F5G zM$oM)-+KZ=I*c=@mg@g=lw7})n}#`u9lh$4Ak4*v}XgL6C%fpDb}QAW(aBx343UlZ`J|Xvr?~sbTG7 zjT14f_s5+wLXaGF(y4C^T2L~C&sUk9l}G~!pEYsI5ENsWU!K~7{`h{aS{`pzsxPST z&ZYhIGR|T%tVN);ICQAxLFd(v&=fFKXPqo|D z^-kM9MS`OCb7f4?c$RMWbD9fhHVHNH*WKr0o{OK|hO?Q=!@o}r8{B_OGFtIr%*?2r zvzwlFWKb#iNvcO>4evo4S6=qYaH4)_>Jz;Z6I%p?CI4IH!1V6-0TC5kuUjmyf${NZ zuZLY{JAQsoMAaU1=FhJ=Zx*#ZnZNhAi;_s8)HL1tkPf%`+|q9MuyB{mT*~wCWK>Df zhW>b#Ses;6=)$pu$l_v{&AJbAf&h12$L+E4L%q58=9%!+;@LfWX?A-A(bFxE-_D8z zIiNnIF)8x%ym*jX@zbA``^%cQUI!ir$=c%Q;H9vzml2tyK(7!=uSnoKIZtD}*K9@4kt3I_T$HeHy#F z%IE1$ffR>#ySsyZGS}l|(GyW0vGtxA_}+CwMZ(V-nIy%ze%jfN7QfJ z+;E2W>F#c!ttEE43wF7uBk{!xWp;T*o;`c`#)mR5_(Nfdh5msj)>m!oSO)pY=*U7C zATe_LZ?Ng{Qde$r>X%XB?mKGFJ2$q7q8bOm5^Lx$g@#5yQ^Xj{N5tUfAIKC|ww{Y? zOhgBCixA-5ZZ~Fb*M-TFMVIW;>mR~0j-#Cfat{LnLVw*+HJi`P&VCS~Pxd5s0o1fO z|L4a3UVWLm+If9C+oxjjfY0P{*gIsX0fPJ<1n%!&ChaprkGzIBbtq6HzGhgt8(=Rq zTiNJJi4PF6GG5wa8^X|4i$N0wjm*uC>)k?bXDbs}fX2S1=eDNv z>~~ANQt83rDe~|=l%iNfMsiX@nd!p(&r#q|U?Bd}7MV=s;)KX728ZxKD31e35i_<= z4PWCuuJ%;aRv}cS(U6y~iT?q`2vH@y_6!AX8BHc@Q#pV0w81Q1cOmgM18x+%z0=au zAw?9I{Ue8+@41htHYv>JFV%ukdoF-96yJ36b6eQIDyda(p|WsZQKC*G^{`{LMMC3whC;>we9xn@4@y}oQAk|7MO`bWdUqqgj1w=JqXeR(tW6Wy|q7hr1 zhU~ojy?7a*oilKI@+2Dp3VPA2JGsA#tFJI0ear2-ib*V@UwpgEsyGzj8=0Db(a~XN zNvDY%kXZ>z@U!imdUX)%K0=zZ5Y{}Mg>3A6Yy#8&Q^F% zgh)*XL--T_+VF(0HUuWVb{iDLM~7i*pto%>UHN$5S#%GP7yI9sVF*;z zdt!Atw@YQVESbMwquti}AJ<9LJg*>IY*)a)&zF_G6OO7MkX>bO!D+u7I0&@)z&0G(jID5s$)R8HM&dC4JxC@1B!<-J$cl}UUnO0>aD5l7 z1KU9|yJTe6o_OagDK~;LML}fY`xsRu>%)e*?TOL+IlDd#$UD%nkT24K!LOJPn<5 zN)zUP;BPnp9rNuSS1{y?vW4!;Se_WDq`9{HEcY1K7eQSLF2g+W=88`2Z%*q6!=)~p zAC9(}jxL;))}DK=(rh+x$6F@6{}*Jrfz(&r~sPfRkIzT%g?#h>lYH|1O5$3f#jmX zh6Ni~X0$1NZB?R36KDwio6??or@Y*~4EoFF=#!oFwfWIm$Ak`I@2nE==Nj~rskwo% zi`0(X;%0l|gz#HqaeSyMQje2bkFv;2k|!Jn9)Zy#l&`xeZ)a<(=No6GCH3IsmF9R? z+Zl6nxpB)DHMUCl+19qzooHsXgAzH#h1(USMMpj7RnCH0B^rez2u4O$*`O>9?eH=k zazL#Gcd8**-dWky&#DN985!N95`MnLB2=+&DECW}u;)+ziK;~k)~et&bWkp?2}kLQ zeev)3GYC&(G%cIhD4n+G3MW17DTt-TUw9zOjj}$`-n}BT>_i^$nG!pZTK>#7IGm%!MW$`mU~fd#7j_i_9&5}Sz}74H1_ za37#T(x4+3a?v7voG{@{w_uGYwNjXLRR42hhATLSakv%to!#mLJu*dIZ$pYzE8Na9 zvbs)&S7D=5s*Z%){4MrZS>{U9bb z&=?Spv18|3Y}$_FS2~>tjJr{X+9fqE_b1sOjwMa+tt=C^CZ6wqgF>wGm-LW~%E+Lv z_)1@1Rz<3XO@WDQ0>fBjO*&%97Lm<7G`K1(c!00)D{~k_%nhAnNXaiF-~$nnAQXP) z!$FI%l!rxFwOP#E{ikDnd&k8rqyL z*Q-3hF34)Dx4_k$@vW<6maiRsAq<^;9MLk?p`Dh#$xEv;XRo0=G_3!vv3bnQB`Gyk zU0r?P-@h^*p!B-0*&JViICxo}n=RNbpQvJ0Hv%bZ7GjVUxr%V8oQ}1r-*|0QAIM9a{|V zj*9*Lbd&elFhT1{S|gIxWa$WYzpV|T`UcnJm>iw1#CU#_EOA$gz4%^wWd=gk<% z`$<8_ICm|BFT)J!X ze=x#>SLx8q_UQs5!rP)S2lY6?)^*?3wDt&{Xob5EU$Wlbx|NOkeE$U`b8-_07=xX% zk9Uez*N*!%y=v{h_nRA8w#T3D@kRIj;3a+A^u*{WOQz1BdBsQj97NzIu z*C|PCJ{%~6@jDay@a9P6B9qzJz4o+v@7EWoH=3RT=nd2EhH^TWx6Y!uTNvV9sh?ZDlXI0SI zedt@6*`7wqUTvxiYOY`p{!qMHAkxTzfD;~|#vB7#cS%2gKso*m9wyM$?OT1J3oI;2 zW7ccH$C*+}s8;5fBa<|k?Xdx{HFikFkChg6?8u7?lvkepoYzmjw?~=x`zI~v%r(8B zWU}C1r!Sws+URVojMSWVa)-hG`AZ{7c+?a)$2Uu?vK}3RKUF*pX4hqf*2Yev1KEvR z55&LR*7B`h`!wW!j(XausieC)Lx*#c=)2G)fF9|eMSu*z0-;8O>llMXNJV(jsc}K* zwGg-^0oLhIL88IN{z7<{<&?V-mkzhOm z`AvFd?|HR_IyVOCY(M6W)8pNq?hBr%mpzIOlG3&pE9XCw44JYfDLJd?-Ih<6YKQ?| zzRTf1X00~i^K@w>*T?478p1Mk5RaaSO`p!`n^6R(becndj|W**@cPS;%KavmjIAsS zX^ONt1#E^#U=S8hJf9ewS(y$c8kz;PT965$qzK(q zM>Mg@d8yVM!y=!G3l(IvFNh{17jV?7BGd5g3yw8z8aMzCjx|FD6*)wQwIw-4S3do_ zNRq7yf8ooFu|%ba=Rxlj7uRZ?1(FLs0k8E1I?R?+LCH`vQCH`|@uE|I_=wBp#Yltk zlvK{jykxZ;8nPs>cTXbUsekcL&0n*^8Adm^%$RxcR>h8Xut-og^mGmmWGcjcV|%kP zN8xGegw6}KrH_WjKQ8nQin01s;!2i(RCyoc0iBtif-7$MyUYnuSmu)aI&3nkYWm4h zb%e!ls@0K|oO0#;p;B$k<4&V($Z|H92b^GIa)qEr@aMxyh65s8>f~u7T=d4(u^Skx zlkh%#h>wXWC7-pbKTDJ1Qe#K|U2^LS*qiV0g>`rsefmd0xV0ecN9d&mY3mo(+%}Rp z9aHcv-@LtyU#ao;>4U|IDNqloFZL{BZj@!vmtyw`uEK?F0-~cI1h}t-rM`PZhW{i2 zQ8mvQc+ob^EiWl$bY|u_LY_9h+Ou>c2FJDj8EWp*9s2g|+lTAByPor>n~U_1-XCX2 z{@<}LNgizU`uuQxeLJ_XChud%v;qTGNr&Rm_olwW&v3V=9jiK&v-qV^Z*3KPsnUxW ztb(slCod|mYDFfA3Ibq>BeOuLyOg`zjZ2Y>HNGDHoEl~@eaQ9_Z*$}kua$N_l#vW* zKtv{Jxgq0VsXlz7<{3@b2KfqySXzju|FmeC_|Zneh?JguN1V3 zAJqDK(Ei!en0iKcRxxX*wtB^$G0aE@=Mr-zC7;ALQ8!+PZv>uu)d^je*w>#Q7vQAD zK%iK5Y-JDrYA&&`{!khyG7)@qG9=0nR+Jy2PyjGwWayhMgw^jV{Q;kFC>lZ^-c6M- zeP&oO#UhqeE0@pK0_D|kEciU_dpVo1>@VdWDO|7^J59~izq7-%Qp%(Pvvp+cT{FQfP5c^9TYAXaO;v)|E!Nw~}QlDk{pVtM}riNoW#f@>%gtPd4|IzYO+Gq|^<;tLDhY>9!>t>I8dlX zA|(PLINdj01P3vx4bOSpTLXG$b8-$3;O;by(>XR^=#^Mw81J2rA>E0P7qXr%cBgoGkS-giUN6{fB% zocyhjdn!4Z_W)`v#y+r`K3Lh^uDRM--eUa(zZlreOmsdH@`%&o8hI&amFD$z+djn!q%bum*~w^fOODnPKyt?ss}^v67n8#;?r9+l)pK7N-bh2 z->`MOvi>T!wi z$upbE%aF(#DRXCx_Y)+>|I|WY(elW%qEqIM8S^ub2mYyNZ))#4#~f?)-dk_t3`tw+Ru-PrbFK=scqGXz zT$813-0XgXo4D&zx%;v7^7<}bf83}>1ghEXi>`sBokWZ{|J41a--YkKm4?IS@Uxx1 z?a8@?p=XRKzZei<-FVaf?K*^PAz_f*XuQbil%Bz|qN=E_Ea^!B3Gf7e_q-ffagX9a z76996-(H~|xfU0qLsen7=(Ba)hPZr!T>dz4@7<10*M|)R8*^I<=L@6uCIL~BdzT$xp(9F>d( zhWG#(zyJpO3ij9Q?tflzjpLHt2Iqf|)nPU@UDyE*2DcCw-4EQG&Nq>I?}-nyTU;R~ z0x`Oo^<$KFQ*M*`yf6=QGzfON?WEAcB04rDkQ4BpOjV+G3QQ=%&%85&Z;0W+QA>McrYorB?Bdr20Eg$d)cl_ z=u&f;nf<){(?5E^*IZ6wwk|zzb=bJmh8gNk`*$2Wvx4hH-&a_Fy+)Gto61df8iA4q za%eDTBi9}x((OyO{bA46i{fe4S?l{c4{OpmfL`MfMlUQ1jJm7~oE#HSW;d`gCTmg3 z^P-XxB4flVc7tD5E$~(YrC;V~8y2P7AYNtDtrT^z2o0MhV@75o4a7Y1=M+dB_SeH%gy@9DW+<{B?bpUt1fD z-Fl_8Fu0LeI+Hvd#}MqgnfcT6kttrJUa|lMv%yJTuqh#aaXaDjQY+N@3yq%oJ;(9f zYPaq4C-W4qsSy=|?3txLnAMFp+A@T2W>19X+~wmXWocz+ zR05hO0n@G{*Kd#VZEY9{S)jadAn#S#Tej4D6{*>da z->AX4q-lZ&U13P$qV}0`gQ649Zx5VkAd|892ODwMEpeRSo15itZ9#h#bZaB8QqDHp zxYKdxAaH4mTk&9U#)*DzBt0H6uRD+=%<|3}UW{ml=nBZ9Eown`89E+v5+T0X z-FVaM)?JVBU7Y>W$1OK;yEWDiTjFxkPWn*E(hVzH*A`S!UGlOMj#yl-IWqb9q0ewiu{Bt`8$n$pZCX*3j46;rug(csQ z{o&)*efqVRigS6HLy$8f?Ni2va0QoT>8NS_FD|Y*J)+&QssT>5mLf!q3HYQLGdL;h zAK$eHzdQuvO2U?qqQZly!)+K4RaJ;%YwOGNFZzshESl!ir@U`h8<)LfP(xS!N`uA~ z#|Q=7N`fT6NAmd_qo~?8E#azs(NvYGWt26x4DliM$?_rbPlL%f7~rtsNt`@@lL|WC zG#D>Jqn|KNhMLd4x0=rraez`q6Yc#gzA2Tr-bBk0}13=fy}PR1A-+%}!*1fV=Cwa7}D7O2&vo{_1#P^dZNo z{ZU=H|3p7j`MgR?gsb&;T;!rvnVlTh1`N4<@vf(~8%6R+iW2TO6;la`O?PWo#A-T1#xK29%B&yzw)%EsfZ z$aSxf|55bE9dVrB??_vJbEYsOPwU^Us2YxS0E-(+Q5O2 zI6#>`flfj|l{s&ziMM4l1< z`NY?(_O6WL7Fvd6jXTpb7^{NJzuK!64!4gxpVD_N+m7sXr=9_2&%f^zqE)TYggLYS z#1BG`;kpD_RSj%2&RnJqTBwd}Q;X8-WB8OLL*B{RIJ>_{R6lBLdY$yfCv;}v^S)h} zLuVr!@BB2OL(oW&Me%;2DMQ%+@xn zw!memD{bIedu#PxsLvnCTvmCA5jR{seA2{;TH`VF_m_=^lV}>gyU~HCvl;&Zf-OJc zTZ@^p@?r=&nj{YzzVD6H>C(=xb>TP3BEsT=nH08JuyPo#5T-7CphRo@wYV~$iJm$a z3Ro+$Mp1YSDO_Ao!pyp0?F}C@?+a!LYO9XIubLDbK{CN>C0I5#oZ9U_3(12;LN<4I zhorLjHkEbbMXvT|+bAF1@w%5jF)(=s3jSNDeU>55r|{O5^iiOlTU-Y4PFLgQJCHV~ z1RFOYq7m-!Z9B&UA8uv<4)BKfsWAXYQK8E zbqtp#dL5a&);bkWyG4+w8DsyOhyCG?^I%SQy6@GN6xdZo7KTi31G<^NooQXN zQ9fxVPRA);l;$?hg^HBBC-#8xY^$x>?WXDjKU#ftRn!H9EZTr2Eq3uydK22V)G3Gu zj+Bk**RVaUd0%=jf#7pf1jQMlWL7!mL#xU#nI*+!mj$rK8G9H2fH+!={C|hkKUB+l zE=XVwZCBq4FkY|MNCGPXd|3h?I;Q0jn+ql|Ws|TsZ-;ljb{J-FhaP9d4Hg>I(N$&! zb$oJRD&;_=aAVp%6iou*>gVMpDbzxG zPSj{gFYDjt&)&}wkn$>Rg0N9!lsbUMvZ|2>{R^B@0wGEsZ5f~C{%wT6td+dj{Ja`U z*#3$7+2bt{%FS{IvGHP9m9*8YmhRAN+j;0&JwcyhK_j^XFnQjrxD(S+0-!>@Wl8{+ zDE7UAv;>NBzmSG zgb7?pia~$Fk_6coaxfg4d>Yyy!K+D0X!R6{9o)7_TuX^fch(qG7hM!{$Dh$|x)((a z4oEIpC}mZCY?cxL7C}t1*2>{S1$lAtUXBAkBC?RI!Q7QVOROhoU{k^h3p3%Q7fv() zOr*!k^=**1r*S4mw{^ov86lP z2dI^1yZ>*k^yxbi`S1d&>EndR<@Q(q7cL&(cZ^AaN3iB9y|YOFu)dPUuupxW0diN)*otvdK22yp)#!Wysq!OCFQ$vooZc#C}LHNv-Xd)yRLdIMPsu7ZIGIeJ*% z>Yg*a6l`fNkYQMo6P5L3ir)1X`JxwAQw1BTp{hI{i71z_%@cgbFhENo)?-UYtQUb{ zEN&Q&5O2t7qhAOi#E3NHC;@Z)cd3LD#JD==;xcMXNbBwU$kWqThodbN;m=>UNDtJp zChcsPr`JVRag$8h1J71Mf{iNZ=`$8rP5edOX5ybNsDaZ5H3YyOfuwtz&#ID-O zR3OrGd|GbXn@iPuiF{!5P-JNHjB;gGqINJ5h+9^v?i(}HUeOweE6>|0TPLy5crn7A z2WEF#9+WUqD+TK8;`X1Z6*n5*&^&e4dp)ZgPmnG;X&jYCbRaQm0x3F+hqzvqd!5UT zJ7G_yQrgCcw9!C;OmL{AS}ZZy;J$t2cB%YQ2U`g6;+!ESi&+g9usGRdmwAb6C8c)B z6C|M-B+FXU1XuSIJz)t>%(AZEE?^fcbtWvAzeg+%|5v3J+7Cit-Y$w}{dU7|i7+qHKLQaTVE=vwF96 zY+IU{)JtEOBSIfuf(~!AFv>jn*=9rL@~$U&0NNW-$nTC4i_`wR_jC2H?N*$+<+mIuaJ*dS@=6QiI)`mD8hQ^yj7 zWj8o2>aJi4#JIsa>}j(RArz48$&{gTgUSpz5MF9oNU*l_QXc|7PasaYLWb?oB0 zy1as4F^I9Xrq|@=P18j`suQ2H@GHg{V4=le$!dw#RuTaYrb|D%=g^Ay2on01Ca@#g}zSW;PB+Egh9 z95c%sMPf3_DkM3}pHmBqJ%OobA5%*g>N8}C@(JaINBZw?8L!v(s5wAXd1 zYQdk>fK*Cg(cD9ig zuO!Tlkp@yOOBr?!dFK40`u4PY?FL*tyu!*#8M?t zdp53!(20a7=kW$xUmV7zIap*kRj|z#pQl`rhO~M8g-s&al%$i;*QfXBJ=Q1<7aDPi zPb~~h1NKk^Bjl#}LW$nKtyCk0!Oa^QI%XV<5RFsix2Lf9-f-yMb)#H&`ESTesB>M< zp&`2H`QDfhTb)mJPLy+WJ_sGTEQ@_DbD&i;qYOFQM>8LF`9+75($A^zXM+}N(dBzZ zrj*i_WKbcy-T{=y##6bj*AYz*Xl5HVgE}x^dDj-~dWAV_Cz`KvVoVy8lb7$V4C!BQO`07pOWH~^Els*;>ap%+;6HCV9h2ZwXOGV= ztqo>>^*|pX_GX14X$^VKV)y%K5ZTExV-(r{f@qc4;!?s6m>(0isrBp1MMPR6fqMx^ z8QaFF@yGT{W@~#N};+LCtF(@g-wNmFIpNYwP=Hj_y?X`Yfq5w!pI=Qr(xZw%Z@-IX?Z7 z8M@v84Tc=``cMQuGKE@p752c>u3IO0!UDD5(0=0c{%^wHZv%VPJy)(*ALmPix zDw#DB(;=qV?6vZx)JGRm7}IEOB1p;mozZyveh$WjPD2}H2l1|%Q~n;L{&{qp8vvS{#l+0q8#P8Aa6Y{wSe)SO!Cw|U&EYq9GSBsK0`w%hH0aI zP;dB`w#TJ>(hHZ;bf7L*khdZW6}N(+qihCjpSb%(9g|u~O9$Qb`8zsTOZqxy1o0-= zrIF3L_wX}9H zUmv#fJzUFvT->^(#E%C2fHy$W;WzB8C6sy6^CGO`ocKH64<>l+hIVp%{}%q~u@v61 z#5|>MyM6c&mCV*^eicPGDc-*+-W&?)Wu+`b6Sayx;|CT zFNrh?5d_zij;gQ)ugS&LIj#62o7_Ou{1^FVBaZl{S!}jZdL$gI^=^%2uY_ICcVv(8 z>pVX(Z>*^IwAg^SEKKRSF*>IudmE<;{-hOO1UHY;!1C`^8Da`Ug7n3) zDNqb>P?e&vDK^C^`lygtI`TDOid%m=UQD1q(J25{d~}Sq0#fuMlH?7kJNxeKQcF9$ ze5d0_tmW6z_zXCxjs2$QY|dWgkMim78wzM2_n=x%q-axnOx@DaGAt&hD%`NYM;}j} z*8h~pe7v%{LZ>phtLy)IR)Ny!hQ0MP?E%*MC`Ph(q5ILl(DR0N=VH-db}p&(n!I;A z?5;w>30p;n0E2sax_g3@3%tqVCh=piEI}1qUSORtjfO1gNQVZ76fDm_G#57ssq9cqHzp?LtK2I|K8iXGW#z_i%>j?vW@{4u6!2 z4ST%Un(=0SspePEP+6R&|9M_VzpJLy{tm&06N{1d^?->$d*QJ#rbomTK5dfcR1M!`Ra` zhNufdP5nsgVY^uHanKxDt3Bm$1y+`haRP+-N&BWvu=$)cgcKJ}Ra;e|kx`ZxyRfq& zDHvHKK`P%X4CZoXf&0u*GTdXkl^bO9DGZV!1Qmcp2&^i;$_jFcZ*6Nx?yWllEG&ou zE^r1AuUa$#;3FBIG=`Ws68jISGSWwCqi+X&nKx>G`jU>()WR~HI5{ni)+`7j= zaxiLwyvMQCPHZR18Vgpv$4ZPLxak+un7|(}@0H@-Nz=5QFmWhER@-IdpJju?M!412 zJBs}*wTw|qt|pF88_$IXw}_6Qfzd<{3rUVU&nUQu-xysJy|CT{6YI5-Dl>Sgvl`Z-UDTtedCY9($ z+?33nTcx|;OWJ%s;sRm<{@I{19wC&=p(i-0ZnLdd-`VryB;~)edV|tRquK3$NP7Q_ z?G4L0PQ!S5KIctfzIQM@Hw~9x2SmExB#`+}w3=&tr!j0x7$@Jdp~*xUPAev!j+juc zH|{@UpSK_$tiYmWl*F65;EfkyB`cdT{%&H;~?y`rx~d zzqZCQ#6AT_Q&iR}tb>NE{sk|RJ#A{cHE3K5l8$P?1TIW+4-x_!0C4x6rOCcmR#Tee zstu8g2Q@qnF?q1DW^HGa^n^e~AI`7O$x#D&nAK_0w7pV2u1s6+*Ow6*DGi2wgleDF z{x++!;wvem#X64ZPVWRYC><3*U?fO8RU1S`KF z4&g4WB%C}@&scG0^W%GU!Ow~$Ei=A3El7rKzE5`R&}fG5{q7gtduVz*(fv>q{h zLt-Ey849NjuF{66q6I52Y9gI1&#l0mPlbuN$Ln7A*5YrNP+^HD#jGt&ou2JW1YSnP zRs|{uL$!p;=CK7X=QxtLN8Jt2J(j1j`s>Mc)cz*dAcW} zW%G`<2Z(g>{O#pHlZ`Lgh723K1aFnuDw`++@DPi5#~}L*!K%1)`Mon1H+p?GJ)tMC zr~5b&+n-^y+w2iM1aC02o87-?YKpmer5v_fzq^k>2;TqE>3*JjxUS~OkKAi*T_wcv zyRr6rOV_osx1+#^SIlhUPLfvE)E=(sdDBiK@uQ`HD7M{Lij?%Jt_OW`p(Wg1Enh~9 zQ&ZN}|ID5!%EzoDD&a=8c(<$A1ndE`0=W-j}IN^Ze3ohU9+G`J*q-Nh59) zJsyo2sle6{=}NOS4k8*?kTz%cJGq1&sqG$I(mN{xNf#+2_;k_SDmLX~tN%d3CmXPt zue6c!B-QSK=>um!d)N7Vh4KuvJlGzte5&xFj9S`c$G@lIb}lpFm)- zi?kIQH$_VLsb$q_e$*EstT}4(K6p%SZ2`q*R+@&#*YuD-DHp#m%Od%;WM z`oF_mihS6116{Az2hB%z9@p7e2OtBU+R1jl4{0TZ{FN$kqWwbHB2*XYb?$;-SC5=# zw1YnEgn>&QaDEs`BMA%D9~z=9!<=nKDLh&C!eXRoA~YtSHa}EqRy4QL##m;B1;rXt z)>0m>#X0=K(vk6U(H_3#|7MNsKcLHdl@KhAh?J;U3O~&sYbRi;A|vdvc!a0$%JQ)3 z$03(aBBpLe)tuXh4m}Q^M0{S{DJZ(u9R0AmDln=&LOjl;`HFLkGYvk1fZWG9>yh+nWh27@fR(_zD zsr7w$PG_&3x#~`R(#oN%r8vA!Z5X2|swiDN3;Kr|bScEIU*eG{v7`{#cU7^!IG0uR zzsm7q_o*{K*~Xc&Ya_(p5s?XzAQ)AcwTvGx%nsB4gGU1d@ zh>!z(895wi`bk^endHXs2X$VHFjDJp+)0 zv=s$Afspz*4R|rwAal$Sg+k?wG70u%(hS_PiX!Y->RiQ7Q9}*uKO(|T2#BwP#8}bF z#34KRY{#8y^_M%SL!Sb|C@>dqIW|H@EafjvV`-qvsVN zUbriaN$9Pyxyb~7IE8ZG=~J47U}9ONLk3{DHx}7w+_|S7fJKTQmoUBZtBFfRq$4zH zjlP6z%%aP6t7w~snn^iHmJGNR6`0OBxl$W;R_*c6(h^!4V5>d{Y2=}>VpkZNEHBs+ zc}7KPAq54^S_GY$vbz2h_driESaITL1))Z&W58+7^EJk)F$35o_1&vLV0gmA2&uiE{QigtjqD+?sU zOe-cgsjetuYqsUV(#z`|Y}1#M#$-W?nS~6f!&1$z4K3!;T6XXe@=Rz|q<1ElG3p#d zHUm2@f{6&!`f*q&6Y*fPkl=%mpU$7m5f^F`K4HSIPbH1Tv#vI4qFQ>a55JHP_T8+KuwS@xHW5c%>cO};QYcH(-1^hzA3EgY?-6ZFOIo>@u>@Sdblce{& z33mU@t;Abv4K?Ndin%H)Z?Zt%Sip@<-sA?s^Y&t8-oygET*^9$Gz)18yB?F+u!qYY zK&BwME2gE9Fdk(MsA<6(@U0U=`5aYn_qsYH(5yO{RNAUrniFUb$O&Q2+g%^_tqf`d zT(YxUUoOI*)_;2xlCuS=bE)xge_5UtHF2S6x^rus$s8ypd0~ZN>uSJs-Y%(Q3xJiVUKsCwE&L&vDwn%4Z#Oy}Y;&J+2x05fd2!XjrH(Y7o@ zO8F8&c{d*_J3tQGNS!@~dEdpFT|LXKr`6>OH!Pu6$^}Z}@GwG95^0Ysz@j#x{OonX z%|vDyiW~QEr8n{>KEPJOI;VpLDG-^4Jl@0>axm4G?QKy&=3r4fi5i9`epdG*+=``j zzzIu%OZ_;jV)q2-X^%Kstr0^(1#Be}^x34jaw@t@{jbBpurL$`2n`yRdV0$e(t&1S zusUeQ?NOb%WEMa%wYpKn4joQ_2I13I&DuA&Dyw+L^;s6+(;ea}(ebklAc^}L`BZTyvu|_G_#Gr*K z<|B_snusqB8{Sul7N=F0AxgU4@!@N*_#d350kB=qWH!oY<>mZuc?nQY|D+Yi3uk59 zx5EV}>A4|oy-toi{t9*2KXvfW+)>blKQHTmea$GLwJhO8b&tC#Gp}wt!TEXS#J6|M z&7R4oM40sH2Nb?SZ-h|?UZXo}Nn|l+Fh!U20I=s2MMVfl=-29lqT=ia43muY_|Pm< zoDrDv^AKj5?Lr)n&rc{&5uI<&6c1+$$(9^e9!p$_mm47&+~AOqroul{xv~1X)PxyP6!cnv*~m{oSuVB zJG)Bb$s3-NBA%rB`Yim>enz&o25=dwB zbNVctnIZVlu@V)7tZK~U!;#4ATX=2Uh{*kw>*7X@R>iC#xg*1{ygnzWumswZ^R`)3 zr1XaFTROm>%)0XawqJ~z7%JgY)$Q@`w`ZnKZsU=$M34DU>`g0VhB{h!W%{qyjS12e zAyyf%Rnq2RksxrWXHr z?mBhfuZL@O?`wqsXF=R16xE=jx>Ao4`g21}j?ih-Cj#o~GgjVtxy>;$wx}@LDugkj zvYl_DMqNQ!RFLzCn3Z@qfVe0m&6ZnEiCeC>UaQ#`Pl^}|1P0J>=H`^Wyf~-vP2uR3cfVm+#1tr_)*=w zqUyN;2~X95!*i4T0C^_C(40(>24+C%3k$g6?ctP%LjsOUmwT6%C$IC-=Yv|MBmXpU`v zD@8`qm?rJM54)Z3+6o53^)teP3}GD%vda=$8IV#cS;eR|DCGsSi;FrD84Qb3vBk+J3ZDt(ZvQ;43I%yvNUJ+F;#OeVj8Ay`?Q+Dad7CZQ$#A)huS zgf&o$D|fd?Yk_l_C@GJ=ORdIUrBBEcUUef)7a2sRuZRMKYwUet{(<5-{CD~@nRm=A&a=2U^!sg+nrxP?8uH&=%O*S(8m#XsGMNiU zSB{V6)354*asvPEcss?R|Bt-XA2*!P8MfzX$^q>0>Zqp=rMff7M#PVfr2Dl8Z>2TV zlRcJo7{eUFfJ*rfX3npB0sH=FX^F*7>h7CN2gmhswrPQPd6gfK=#l+*I!vX3v!CcN zb)-`?aLLsk{iZSMLwb{_)fcf|@B5^*cxz&BH3%g?v~mi?(Uno0E~t(Jrps)FTabFbciY5#r_1SXKaouV4Q zzq#%g=?+yV9pOkTz1iHg8bhzdOSsoau{j=84dLbdmirgcVW5$bae_Qs29=wkZcu{jD{c$K1ET%;-IDskCYqzf>P{b3;r_mb{O>zmJ`eC;t4fHkZ*$AlzAxFq4Lm zaS|l%BQr=>ZF9gS_TJiveudW=iB3$fK57rt!2|8|tq?`Jpl7df6i zO_st##jTUusO6ZM&|d-faPUDcV;-HKxf$*=DX!s3j)tGh%)skO;E3gqALNOOz{qsn z+@J`#OqDHf2>B^P!!Z4%Yr#lbW;u>ogV39Ft9>`v7qC;baHNb=iR!ew9afDq`gl5j z-EL_?MTUy**J4CBQkCNQ7Cazc%qE#^)kr(pM4)-TLCBvUX!t8T`a8J9Tw)o*?F+T* z7iGtjz7R$=&z=jUgmB8zH{=Ux0JPpRRc55gC}x8$_Po~m za9N!CAP0q3*kB6yyjM7rKxdC$3Y8JvcBG;gjntB&(dbxDSvxak9I-4G| zzHwQLz2Wv=s0>=4IWY}QIVbAd$}u=8DCfl_$UJrxX_dJ$Gf1>KCl%IEsSPq;eOq+` znIi&0#*jrJpU`ZV`IAl&wDie3kBimh>!Hx$yoAP5_`Pgqv%~_ zey!WxZuk)iC0(LAdcA6c4_Qy?A_~L>|H7Ap{4zh(j<7Cgv+J=o+0rD&SXb9u81@CE3(s0 z381Sm)i|=;>8pjF4MpyB*&Ur->8fFB-X1?8M5_;^{!Dj3qV!)kGLhwW5|s_@zW{H{{X*HObfuHH`q)FN_36uGYxyo zu4vsZ@XBE?&$KMb>1cn}=14KKU$BLiY|mJ5gk7woM$b&_zvar!R#bX9xyZm@U<(8l zrrxY|gbu=bbKFjfLeeqaxMMF|5mv}{;HntnYt%7$U#={|b?92+Y%zR`mb0I!4~n6L zaooVfs^f;1%r{<5oB&&Cb=osfKij*N9moei-s1>n)M526lUtz?eB2KHkLEp3z0!d1 ze3S*}1C9a>KvxX#0P^^|#PwzJ*GK%Vt{c_YTNq8}KB~(SpDF11RYGZHa!7?q7KWm( zHggs48XZd=l$@D2|E^HLd_7I