Compare commits

...

56 Commits

Author SHA1 Message Date
2e0cd2378a Merge pull request 'blog post and other fixes/additions' (#124) from staging into master
All checks were successful
Release to Production / build_and_release (push) Successful in 48s
Reviewed-on: #124
2024-11-14 18:26:40 +00:00
df0c64e2c9 chore(git): merge pull request #123 from fixes-120-117-84-104 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
Reviewed-on: #123
2024-11-14 16:02:04 +00:00
enes
2ed81c857c refactor(comments): reduce initial load wait and add discovered 2024-11-14 16:50:37 +01:00
enes
18bbc12776 fix(comments): add initial loading indicator 15sec 2024-11-14 14:50:28 +01:00
enes
f7d21807a4 refactor(fetch): add 1min timeout on reactions, 10sec timeout on user relay list 2024-11-14 13:56:42 +01:00
enes
cd3c7ace01 refactor(comments): add dots to comment reactions 2024-11-14 13:55:48 +01:00
enes
aaffc56424 refactor(reactions): use dots loader and block interaction while loading 2024-11-14 11:31:13 +01:00
enes
7b1a70446d feat: spinner and new dots loader 2024-11-14 11:29:01 +01:00
enes
4140438044 fix(comments): link to profile from name and npub
Closes #117
2024-11-13 16:41:15 +01:00
enes
d6bc3b8684 fix(profile): accept npub as valid profile param
Closes #120
2024-11-13 16:24:19 +01:00
296b0ad61d chore(git): merge pull request #122 from fixes-11-13 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
Reviewed-on: #122
2024-11-13 14:13:26 +00:00
enes
0f874c6bbb chore: comment typo 2024-11-13 15:12:45 +01:00
enes
297de3999c fix(comments): hide if missing aTag, force render on blog id change 2024-11-13 15:08:43 +01:00
enes
49435c2b50 fix(home): add missing spinners 2024-11-13 14:30:58 +01:00
enes
718350d2bc fix(blogs): moderation and missing aTag
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-11-13 14:09:13 +01:00
enes
3ee9e313de fix: remove unused var
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2024-11-13 10:58:30 +01:00
enes
91830a539a fix(home): latest blogs published_at sort
Some checks failed
Release to Staging / build_and_release (push) Failing after 23s
2024-11-13 10:55:51 +01:00
enes
834701aa2c fix(home): latest mods published_at sort
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-11-13 10:43:49 +01:00
0c7e61cadd Upload files to "src/assets/img"
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2024-11-13 00:21:43 +00:00
enes
b49ae9537b fix(blog): nsfw filtering, use L tag instead nsfw
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-12 20:15:27 +01:00
enes
352179f1d9 fix(blog): event fetch filter, editing as non-author, add errors
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-11-12 14:25:34 +01:00
enes
77b6aa0d75 refactor(blog): missing blog data will not trigger loading screen 2024-11-12 14:23:58 +01:00
enes
2c31c279a1 refactor(404): more generic error page 2024-11-12 14:22:54 +01:00
enes
7be41272a0 ci(env): add new env vars
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-12 09:58:50 +01:00
cea814676e Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-11-11 23:23:48 +00:00
bd6515ca53 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-11 23:21:04 +00:00
fedd7dd463 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 27s
2024-11-11 23:19:32 +00:00
7ceb109bab Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-11-11 23:13:01 +00:00
b3747e9c22 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-11-11 23:10:21 +00:00
d10b10a4fb Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-11-11 20:33:03 +00:00
2fe0a79009 chore(git): merge pull request #118 from feature/blogs into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
Reviewed-on: #118
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-11-11 12:00:59 +00:00
8af73df889 Merge branch 'staging' into feature/blogs 2024-11-11 12:00:02 +00:00
0d10890ca3 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-11-11 09:25:15 +00:00
4176b06bee Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-11-11 09:23:28 +00:00
enes
178876ab99 feat(blog): editing 2024-11-08 11:14:07 +01:00
enes
1c1430ba5c fix(mod): use existing uuid for edit 2024-11-08 11:02:07 +01:00
enes
2f563e1bfb feat(blog): initial editing 2024-11-07 18:05:19 +01:00
enes
f7f3764686 feat(blog): moderation and more filtering 2024-11-07 17:33:59 +01:00
f734b1447f Update src/styles/post.css 2024-11-06 17:57:16 +01:00
enes
6d6ff8ce43 feat(profile): blogs tab 2024-11-06 17:33:15 +01:00
31fd4ddfb5 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-11-06 15:11:29 +00:00
enes
f30ac01ea6 refactor(blog): re-render body, latest and filtering 2024-11-06 13:17:13 +01:00
enes
31ee0221b7 refactor(blogs): curated filter type 2024-11-06 13:15:30 +01:00
enes
c81b2c0a1d refactor(home): fetch latest blogs in parallel w/ nsfw filter 2024-11-06 13:12:19 +01:00
enes
dae94733fa feat: add react router scroll restoration 2024-11-06 11:13:42 +01:00
enes
2f32f400dd refactor(mod): remove unused import 2024-11-05 16:44:07 +01:00
enes
1d0f27d255 feat(mod): show latest mod author blog posts 2024-11-05 16:43:08 +01:00
enes
a3bec707b0 feat(landing): show latest blog posts 2024-11-05 16:22:08 +01:00
enes
169ab37304 fix: get multiple tag values 2024-11-05 14:42:22 +01:00
enes
73a7b1c1ee feat: admin blog page pagination 2024-11-05 14:11:11 +01:00
enes
847aab29d7 feat: add admin blog page, content parse and markdown 2024-11-05 13:57:39 +01:00
enes
3717c3bfb9 feat: fetching blog data 2024-11-05 13:40:28 +01:00
enes
c2413e1bd8 fix: blog kind 2024-11-05 13:40:28 +01:00
enes
d4148ed01d feat: publishing blog, ndx in router, introduce actions 2024-11-05 13:40:28 +01:00
7f0431f8f8 Update src/styles/cardBlogs.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-11-05 11:53:16 +00:00
3a440d5479 Update src/styles/cardBlogs.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 38s
2024-11-05 11:51:57 +00:00
55 changed files with 3223 additions and 823 deletions

View File

@ -11,4 +11,7 @@ VITE_REPORTING_NPUB= <npub1...>
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
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= <A comma separated list of npubs>

View File

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

View File

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

View File

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

400
package-lock.json generated
View File

@ -1,21 +1,22 @@
{
"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",
"@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",
@ -39,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"
},
@ -51,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",
@ -1038,6 +1042,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",
@ -1152,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"
@ -1181,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",
@ -1479,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"
},
@ -1526,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"
},
@ -1604,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"
},
@ -1695,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",
@ -1774,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",
@ -1789,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": {
@ -1803,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"
}
@ -1815,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",
@ -2032,6 +2097,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",
@ -3193,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",
@ -3847,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",
@ -4504,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": {
@ -4521,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"
},
@ -4537,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",
@ -5028,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"
}
@ -5149,6 +5236,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",

View File

@ -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",
@ -41,6 +43,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 +56,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",

View File

@ -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 <RouterProvider router={router} />
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
}
export default App

View File

@ -38405,7 +38405,7 @@ Trap Legend Theme Song,,
Gemini Strategy Origin,,
Brain Marmelade,,
Forsaken Flesh,,
"Warhammer 40,000: Darktide",,
"Warhammer 40,000: Darktide",,https://image.nostr.build/0c405bdb168c05f21f54b3bda39852eb2f9fa851068367eccd69f2bc0526a600.jpg
Cleo - a pirate's tale,,
Teenage Blob: Paperperson - The First Single,,
Necronator: Dead Wrong - Special Commander Skin,,

Can't render this file because it is too large.

View File

@ -45067,7 +45067,7 @@ Rooftop Postgirl Demo,,
Mad Experiments 2: Premium Pack,,
Arise from Shadows Demo,,
Hentai Heaven's Slutty Salvation,,
Fields of Mistria,,
Fields of Mistria,,https://image.nostr.build/deb8fb380cdf2f42750f115141d762de791bf49e0af97ec733390360a1a5ddbf.jpg
Maskonauts: Chat'Attack Soundtrack,,
Nameless - The Departed Cycle,,
Grave-Queen,,

Can't render this file because it is too large.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -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<BlogCardDetails>
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
if (!naddr) return null
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
return (
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
<div
className='cardBlogMain'
style={{
background: `url("${backgroundLink}") center / cover no-repeat`
background: `url("${
image ? image : placeholder
}") center / cover no-repeat`
}}
>
<div
className='cardBlogMainInside'
>
<h3
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
WebkitLineClamp: 2,
fontSize: '20px',
lineHeight: 1.5,
color: 'rgba(255, 255, 255, 0.75)',
textShadow: '0 0 8px rgba(0, 0, 0, 0.25)'
}}
>
This is a blog title, the best blog title in the world!
</h3>
<div className='cardBlogMainInside'>
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
{nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
<p>NSFW</p>
</div>
)}
</div>
</div>{' '}
</a>
</div>
</Link>
)
}

View File

@ -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())
@ -158,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')
@ -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()
@ -298,7 +323,55 @@ const MenuBarButton = ({
onClick={onClick}
disabled={disabled}
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
type='button'
>
{label}
</button>
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
description?: string
error?: string
}
/**
* Uncontrolled input component with design classes, label, description and error support
*
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
* @param label
* @param description
* @param error
*
* @see {@link React.ComponentProps<'input'>}
*/
export const InputFieldUncontrolled = ({
label,
description,
error,
...rest
}: InputFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
<input className='inputMain' {...rest} />
{error && <InputError message={error} />}
</div>
)
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
}
export const CheckboxFieldUncontrolled = ({
label,
...rest
}: CheckboxFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
<input type='checkbox' className='CheckboxMain' {...rest} />
</div>
)

View File

@ -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 (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap addressable={addressable} />
<Reactions addressable={addressable} />
</div>
</div>
)
}

View File

@ -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 (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/MM/yyyy hh:mm:ss aa'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}

View File

@ -1,11 +1,12 @@
import { Dots } from 'components/Spinner'
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,20 +15,18 @@ 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
return (
<>
<div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
}`}
onClick={() => handleReaction(true)}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -41,7 +40,9 @@ export const Reactions = ({ modDetails }: ReactionsProps) => {
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p>
<p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
@ -50,7 +51,7 @@ export const Reactions = ({ modDetails }: ReactionsProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
}`}
onClick={() => handleReaction()}
onClick={isDataLoaded ? () => handleReaction() : undefined}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -64,7 +65,9 @@ export const Reactions = ({ modDetails }: ReactionsProps) => {
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p>
<p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>

View File

@ -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) => {
</div>
{isOpen && (
<ZapSplit
pubkey={modDetails.author}
eventId={modDetails.id}
aTag={modDetails.aTag}
pubkey={addressable.author}
eventId={addressable.id}
aTag={addressable.aTag}
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
handleClose={() => setIsOpen(false)}

View File

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

View File

@ -192,7 +192,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
return
}
const uuid = uuidv4()
const uuid = formState.dTag || uuidv4()
const currentTimeStamp = now()
const aTag =
@ -204,7 +204,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
pubkey: hexPubkey,
content: formState.body,
tags: [
['d', formState.dTag || uuid],
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],

View File

@ -0,0 +1,9 @@
import styles from '../styles/dotsSpinner.module.scss'
export const Spinner = () => (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
export const Dots = () => <span className={styles.loading}></span>

View File

@ -1,4 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Dots, Spinner } from 'components/Spinner'
import { ZapPopUp } from 'components/Zap'
import { formatDate } from 'date-fns'
import {
@ -21,9 +22,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,18 +45,30 @@ type FilterOptions = {
}
type Props = {
modDetails: ModDetails
addressable: Addressable
setCommentCount: Dispatch<SetStateAction<number>>
}
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<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
})
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Initial loading to indicate comments fetching (stop after 5 seconds)
const t = window.setTimeout(() => setIsLoading(false), 5000)
return () => {
window.clearTimeout(t)
}
}, [])
useEffect(() => {
setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount])
@ -84,9 +97,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]
]
}
@ -172,11 +185,21 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => {
return true
}
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const comments = useMemo(() => {
let filteredComments = commentEvents
let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.pubkey === modDetails.author
(comment) => comment.pubkey === addressable.author
)
}
@ -187,13 +210,28 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => {
}
return filteredComments
}, [commentEvents, filterOptions, modDetails.author])
}, [visible, filterOptions.author, filterOptions.sort, addressable.author])
const discoveredCount = commentEvents.length - visible.length
return (
<div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'>
<CommentForm handleSubmit={handleSubmit} />
{/* Hide comment form if aTag is missing */}
{!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />}
<div>
{isLoading ? (
<Spinner />
) : (
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>Load {discoveredCount} discovered comments</span>
</button>
)}
</div>
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
@ -352,12 +390,12 @@ const Comment = (props: CommentEvent) => {
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</a>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(props.pubkey)}
</a>
</Link>
</div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
@ -443,15 +481,13 @@ const Reactions = (props: Event) => {
eTag: props.id
})
if (!isDataLoaded) return null
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`}
onClick={() => handleReaction(true)}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -463,7 +499,9 @@ const Reactions = (props: Event) => {
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>{likesCount}</p>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
@ -472,7 +510,7 @@ const Reactions = (props: Event) => {
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`}
onClick={() => handleReaction()}
onClick={isDataLoaded ? () => handleReaction() : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -484,7 +522,9 @@ const Reactions = (props: Event) => {
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>{disLikesCount}</p>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>

View File

@ -20,6 +20,12 @@ export const LANDING_PAGE_DATA = {
'Cyberpunk 2077',
'ELDEN RING',
'The Coffin of Andy and Leyley'
],
featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2hs4n6g93x55t6wd8hwejlv9k8qcj8tuu5y7fh3ju',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2j6vzlfsekk3nstgu9s5m4wpexz6j30pzxzzljm5k',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2573jhg9trsu6vgav9gnn4dffkzk2ww3yrjejnc2s'
]
}
// we use this object to check if a user has reacted positively or negatively to a post
@ -112,7 +118,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

View File

@ -21,7 +21,8 @@ import {
log,
LogType,
npubToHex,
orderEventsChronologically
orderEventsChronologically,
timeout
} from 'utils'
type FetchModsOptions = {
@ -32,7 +33,7 @@ type FetchModsOptions = {
author?: string
}
interface NDKContextType {
export interface NDKContextType {
ndk: NDK
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
@ -241,8 +242,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
hexKey: string,
userRelaysType: UserRelaysType
): Promise<NDKEvent[]> => {
// Find the user's relays.
const relayUrls = await getRelayListForUser(hexKey, ndk)
// Find the user's relays (10s timeout).
const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined

View File

@ -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<CommentEvent[]>([])
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<string>()
@ -92,7 +100,7 @@ export const useComments = (mod: ModDetails) => {
subscription.stop()
}
}
}, [mod.aTag, mod.author, ndk])
}, [aTag, author, ndk])
return {
commentEvents,

View File

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

View File

@ -5,7 +5,7 @@ import { Event, kinds, UnsignedEvent } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import { UserRelaysType } from 'types'
import { abbreviateNumber, log, LogType, now } from 'utils'
import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
type UseReactionsParams = {
pubkey: string
@ -32,7 +32,11 @@ export const useReactions = (params: UseReactionsParams) => {
filter['#e'] = [params.eTag]
}
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
// 1 minute timeout
Promise.race([
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
timeout(60000)
])
.then((events) => {
setReactionEvents(events)
})

View File

@ -212,7 +212,7 @@ export const Header = () => {
About
</Link>
<Link
to={appRoutes.blog}
to={appRoutes.blogs}
className={navStyles.NavMainBottomInsideLink}
>
Blog
@ -379,16 +379,14 @@ const RegisterButtonWithDialog = () => {
Browser Extensions (Windows)
</label>
<p className='labelDescriptionMain'>
Once you create your "account" on any of these (
<a
Once you create your "account" on any of these, come back and click login, then sign-in with
extension. Here's a quick video guide, and here's a <a
href='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4'
target='blank_'
>
Here's a quick video guide
</a>
), come back and click login, then sign-in with
extension.
</p>
target='blank_'>guide post</a> to help with this process.</p>
<div style={{ width: '100%', height: 'auto', borderRadius: '8px', overflow: 'hidden' }}>
<video controls style={{ width: '100%' }}><source src="https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4" type="video/mp4" />
Your browser does not support the video tag.</video>
</div>
</div>
<a
className='btn btnMain btnMainPopup'

View File

@ -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 = () => {
<Outlet />
<Footer />
<SocialNav />
<ScrollRestoration />
</>
)
}

View File

@ -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<NotFoundPageProps>) => {
const error = useRouteError() as Partial<NotFoundPageProps>
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Page not found</h2>
<h2 className='IBMSMTitleMainHeading'>{error?.title || title}</h2>
</div>
<div>
<p>The page you're attempting to visit doesn't exist</p>
<p>{error?.message || message}</p>
</div>
<div className='IBMSMAction'>
<Link

264
src/pages/blog/action.ts Normal file
View File

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

323
src/pages/blog/index.tsx Normal file
View File

@ -0,0 +1,323 @@
import { useState } from 'react'
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'
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 { Comments } from 'components/comment'
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'
import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { ReportPopup } from './report'
export const BlogPage = () => {
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)
const editor = useEditor(
{
content: sanitized,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
editable: false
},
[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 (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
{blog && (
<>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost'>
<div
className='dropdown dropdownMain dropdownMainBlogpost'
style={{ flexGrow: 'unset' }}
>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div
className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}
>
{userState.auth &&
userState.user?.pubkey === blog.author && (
<ReactRouterLink
className='dropdown-item dropdownMainMenuItem'
to={'edit'}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</ReactRouterLink>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then(
(isCopied) => {
if (isCopied)
toast.success('Url copied to clipboard!')
}
)
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a
className='dropdown-item dropdownMainMenuItem'
href='#'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleBlock}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
{isBlocked ? 'Unblock' : 'Block'} Blog
</a>
{isAdmin && (
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleNSFW}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
{isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW
</a>
)}
</div>
</div>
<div
className='IBMSMSMBSSPostPicture'
style={{
background: `url("${
blog.image !== '' ? blog.image : placeholder
}") center / cover no-repeat`
}}
></div>
<div className='IBMSMSMBSSPostInside'>
<div className='IBMSMSMBSSPostTitle'>
<h1 className='IBMSMSMBSSPostTitleHeading'>
{blog.title}
</h1>
</div>
<div className='IBMSMSMBSSPostBody'>
<EditorContent editor={editor} />
</div>
<div className='IBMSMSMBSSTags'>
{blog.nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
<p>NSFW</p>
</div>
)}
{blog.tTags &&
blog.tTags.map((t) => (
<a key={t} className='IBMSMSMBSSTagsTag'>
{t}
</a>
))}
</div>
</div>
</div>
<Interactions
addressable={blog as Addressable}
commentCount={commentCount}
/>
<PublishDetails
published_at={blog.published_at || 0}
edited_at={blog.edited_at || 0}
site={blog.rTag || 'N/A'}
/>
{!!latest.length && (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>
Latest blog posts
</h4>
<div className='IBMSMList IBMSMListAlt'>
{latest.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
</div>
</div>
)}
<div className='IBMSMSplitMainBigSideSec'>
<Comments
key={blog.id}
addressable={blog as Addressable}
setCommentCount={setCommentCount}
/>
</div>
</div>
</div>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Loading...'} />
)}
{showReportPopUp && (
<ReportPopup handleClose={() => setShowReportPopUp(false)} />
)}
</>
)}
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
</div>
</div>
</div>
</div>
)
}

233
src/pages/blog/loader.ts Normal file
View File

@ -0,0 +1,233 @@
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'
import { appRoutes } from 'routes'
import { store } from 'store'
import {
BlogPageLoaderResult,
FilterOptions,
ModeratedFilter,
NSFWFilter
} from 'types'
import {
DEFAULT_FILTER_OPTIONS,
getLocalStorageItem,
log,
LogType
} from 'utils'
import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog'
export const blogRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params, request }: LoaderFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs)
}
// 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 {
// Set the filter for the main blog content
const filter = {
kinds: [kinds.LongFormArticle],
authors: [pubkey],
'#d': [identifier]
}
// Get the blog filter options for latest blogs
const filterOptions = JSON.parse(
getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS)
) as FilterOptions
// Fetch more in case the current blog is included in the latest and filters remove some
const latestFilter: NDKFilter = {
authors: [pubkey],
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Add source filter
if (filterOptions.source === window.location.host) {
latestFilter['#r'] = [filterOptions.source]
}
// Filter by NSFW tag
// NSFWFilter.Only_NSFW -> fetch with content-warning label
// NSFWFilter.Show_NSFW -> filter not needed
// NSFWFilter.Hide_NSFW -> up the limit and filter after fetch
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
// Parallel fetch blog event, latest events, mute, and nsfw lists
const settled = await Promise.allSettled([
ndkContext.fetchEvent(filter),
ndkContext.fetchEvents(latestFilter),
ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
ndkContext.getNSFWList()
])
const result: BlogPageLoaderResult = {
blog: undefined,
latest: [],
isAddedToNSFW: false,
isBlocked: false
}
// 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
)
}
// 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) {
// 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
} else if (fetchEventsResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the latest blog events.',
fetchEventsResult.reason
)
}
const muteLists = settled[2]
if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteLists && muteLists.value) {
if (result.blog && result.blog.aTag) {
if (
muteLists.value.admin.replaceableEvents.includes(
result.blog.aTag
) ||
muteLists.value.user.replaceableEvents.includes(result.blog.aTag)
) {
result.isBlocked = true
}
}
// Moderate the latest
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === pubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// 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)) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.admin.authors.includes(b.author!) &&
!muteLists.value.admin.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.user.authors.includes(b.author!) &&
!muteLists.value.user.replaceableEvents.includes(b.aTag!)
)
}
}
} else if (muteLists.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching mute list', muteLists.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 the latest blogs too
result.latest = result.latest.map((b) => {
// Add nsfw tag if it's missing
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)
}
// Filter latest, sort and take only three
result.latest = result.latest
.filter(
// Filter out the NSFW if selected
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
.slice(0, 3)
return result
} catch (error) {
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)
}
}
}

92
src/pages/blog/report.tsx Normal file
View File

@ -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 (
<>
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<fetcher.Form
className='pUMCB_ZapsInside'
method='post'
action='report'
>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting this?
</label>
{BLOG_REPORT_REASONS.map((r) => (
<CheckboxFieldUncontrolled
key={r.key}
label={r.label}
name={r.key}
defaultChecked={false}
/>
))}
</div>
<button
className='btn btnMain pUMCB_Report'
type='submit'
style={{ width: '100%' }}
>
Submit Report
</button>
</fetcher.Form>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

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

View File

@ -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 (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs (WIP)</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input type='text' className='SMIWInput' />
<button className='btn btnMain SMIWButton' type='button'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Latest
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Latest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Oldest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Best Rated
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Worst Rated
</a>
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Hide NSFW
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Hide NSFW
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show NSFW
<br />
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Only show NSFW
</a>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
</div>
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-left'></i>
</a>
<div className='PaginationMainInsideBoxGroup'>
<a className='PaginationMainInsideBox PMIBActive' href='#'>
<p>1</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>2</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>3</p>
</a>
<p className='PaginationMainInsideBox PMIBDots'>...</p>
<a className='PaginationMainInsideBox' href='#'>
<p>8</p>
</a>
</div>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-right'></i>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

208
src/pages/blogs/index.tsx Normal file
View File

@ -0,0 +1,208 @@
import { useMemo, useRef, useState } from 'react'
import { useLoaderData, useNavigation, 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'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner'
export const BlogsPage = () => {
const navigation = useNavigation()
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
const [filterOptions, setFilterOptions] = useLocalStorage(
'filter-blog-curated',
{
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
}
)
// Search
const searchTermRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
// Filter
const filteredBlogs = useMemo(() => {
const filterNsfwFn = (blog: Partial<BlogCardDetails>) => {
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<BlogCardDetails>) =>
(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])
// Pagination logic
const [currentPage, setCurrentPage] = useState(1)
const scrollTargetRef = useRef<HTMLDivElement>(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 (
<div className='InnerBodyMain'>
{navigation.state !== 'idle' && <LoadingSpinner desc={'Loading'} />}
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs</h2>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
</div>
</div>
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods &&
currentMods.map((b) => <BlogCard key={b.id} {...b} />)}
</div>
</div>
{totalPages > 1 && (
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
)}
</div>
</div>
</div>
)
}

68
src/pages/blogs/loader.ts Normal file
View File

@ -0,0 +1,68 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds } from 'nostr-tools'
import { BlogCardDetails } from 'types'
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 settled = await Promise.allSettled([
ndkContext.fetchEvents(filter),
ndkContext.getMuteLists()
])
let blogs: Partial<BlogCardDetails>[] = []
const fetchEventsResult = settled[0]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
blogs = fetchEventsResult.value
.map(extractBlogCardDetails)
.filter((b) => b.naddr)
} else if (fetchEventsResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the blog events.',
fetchEventsResult.reason
)
return []
}
const muteListResult = settled[1]
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
// Filter out the blocked events
blogs = blogs.filter(
(b) =>
b.aTag &&
!muteListResult.value.admin.replaceableEvents.includes(b.aTag)
)
} else if (muteListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelists.',
muteListResult.reason
)
}
return blogs
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return []
}
}

View File

@ -1,22 +1,30 @@
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, useNavigation } from 'react-router-dom'
import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules'
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,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { ModDetails } from '../types'
import { extractModData, handleModImageError, log, LogType } from '../utils'
import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types'
import {
extractBlogCardDetails,
extractModData,
handleModImageError,
log,
LogType,
npubToHex
} from '../utils'
import '../styles/cardLists.css'
import '../styles/SimpleSlider.css'
@ -27,7 +35,8 @@ import { 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 { LoadingSpinner } from 'components/LoadingSpinner'
import { Spinner } from 'components/Spinner'
export const HomePage = () => {
const navigate = useNavigate()
@ -114,27 +123,7 @@ export const HomePage = () => {
</div>
</div>
<DisplayLatestMods />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
</div>
<div className='IBMSMList'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
>
View All
</a>
</div>
</div>
<DisplayLatestBlogs />
</div>
</div>
</div>
@ -267,6 +256,8 @@ const DisplayLatestMods = () => {
useDidMount(() => {
fetchMods({ source: window.location.host })
.then((mods) => {
// Sort by the latest (published_at descending)
mods.sort((a, b) => b.published_at - a.published_at)
setLatestMods(mods)
})
.finally(() => {
@ -320,10 +311,136 @@ const DisplayLatestMods = () => {
)
}
const Spinner = () => {
const DisplayLatestBlogs = () => {
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog-curated', {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
})
const navigation = useNavigation()
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 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 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
}
}
// 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 more posts in case of duplicates (from featured)
const latestFilter: NDKFilter = {
authors: blogHexkeys,
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Filter by NSFW tag
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
const results = await Promise.allSettled([
fetchEvents(filter),
fetchEvents(latestFilter)
])
const events: Partial<BlogCardDetails>[] = []
// 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
.map(extractBlogCardDetails) // Extract the blog card details
.sort(
// Sort each result by published_at in descending order
// We can't sort everything at once we'd lose prefered
(a, b) =>
a.published_at && b.published_at
? b.published_at - a.published_at
: 0
)
)
}
})
// Remove duplicates
const unique = Array.from(
events
.filter((b) => b.id)
.reduce((map, obj) => {
map.set(obj.id!, obj)
return map
}, new Map<string, Partial<BlogCardDetails>>())
.values()
).filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
const latest = unique.slice(0, 4)
setBlogs(latest)
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return null
}
}
fetchBlogs()
})
return (
<div className='spinner'>
<div className='spinnerCircle'></div>
<div className='IBMSecMain IBMSMListWrapper'>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Fetching blog...'} />
)}
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts</h2>
</div>
<div className='IBMSMList'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
<div className='IBMSMAction'>
<Link
className='btn btnMain IBMSMActionBtn'
role='button'
to={appRoutes.blogs}
>
View All
</Link>
</div>
</div>
)
}

View File

@ -2,9 +2,8 @@ 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 { 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'
@ -29,9 +28,13 @@ import '../../styles/styles.css'
import '../../styles/tabs.css'
import '../../styles/tags.css'
import '../../styles/write.css'
import { DownloadUrl, ModDetails, UserRelaysType } from '../../types'
import {
abbreviateNumber,
BlogCardDetails,
DownloadUrl,
ModDetails,
UserRelaysType
} from '../../types'
import {
copyTextToClipboard,
downloadFile,
extractModData,
@ -43,11 +46,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'
import { extractBlogCardDetails } from 'utils/blog'
export const ModPage = () => {
const { naddr } = useParams()
@ -143,7 +146,7 @@ export const ModPage = () => {
nsfw={modData.nsfw}
/>
<Interactions
modDetails={modData}
addressable={modData}
commentCount={commentCount}
/>
<PublishDetails
@ -191,21 +194,10 @@ export const ModPage = () => {
)}
</div>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>
Creator's Blog Posts (WIP)
</h4>
<div className='IBMSMList IBMSMListAlt'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
</div>
</div>
<DisplayModAuthorBlogs />
<div className='IBMSMSplitMainBigSideSec'>
<Comments
modDetails={modData}
addressable={modData}
setCommentCount={setCommentCount}
/>
</div>
@ -972,126 +964,6 @@ const Body = ({
)
}
type InteractionsProps = {
modDetails: ModDetails
commentCount: number
}
const Interactions = ({ modDetails, commentCount }: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap modDetails={modDetails} />
<Reactions modDetails={modDetails} />
</div>
</div>
)
}
type PublishDetailsProps = {
published_at: number
edited_at: number
site: string
}
const PublishDetails = ({
published_at,
edited_at,
site
}: PublishDetailsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/MM/yyyy hh:mm:ss aa'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}
const Download = ({
url,
hash,
@ -1349,3 +1221,49 @@ const Download = ({
</div>
)
}
const DisplayModAuthorBlogs = () => {
const { naddr } = useParams()
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
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 (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
<div className='IBMSMList IBMSMListAlt'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
</div>
</div>
)
}

View File

@ -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,17 +14,24 @@ 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, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLoaderData, useNavigation } from 'react-router-dom'
import { toast } from 'react-toastify'
import { appRoutes, getProfilePageRoute } from 'routes'
import { FilterOptions, ModDetails, UserRelaysType } from 'types'
import { appRoutes } from 'routes'
import {
BlogCardDetails,
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy,
UserRelaysType
} from 'types'
import {
copyTextToClipboard,
DEFAULT_FILTER_OPTIONS,
log,
LogType,
extractBlogCardDetails,
now,
npubToHex,
scrollIntoView,
@ -32,65 +39,27 @@ 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 = () => {
// Try to decode nprofile parameter
const { nprofile } = useParams()
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)
}
const {
profilePubkey,
profile,
isBlocked: _isBlocked,
isOwnProfile
} = useLoaderData() as ProfilePageLoaderResult
const scrollTargetRef = useRef<HTMLDivElement>(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`)
@ -279,6 +248,7 @@ export const ProfilePage = () => {
setIsLoading(true)
switch (tab) {
case 0:
setLoadingSpinnerDesc('Fetching mods..')
fetchMods({ source: filterOptions.source, author: profilePubkey })
.then((res) => {
setMods(res)
@ -302,22 +272,6 @@ export const ProfilePage = () => {
profilePubkey
)
// Redirect route
let profileRoute = appRoutes.home
if (!nprofile && userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
if (!profilePubkey) return <Navigate to={profileRoute} replace={true} />
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -482,7 +436,7 @@ export const ProfilePage = () => {
</>
)}
{tab === 1 && <>WIP</>}
{tab === 1 && <ProfileTabBlogs />}
{tab === 2 && <>WIP</>}
</div>
</div>
@ -703,3 +657,189 @@ const ReportUserPopup = ({
</>
)
}
const ProfileTabBlogs = () => {
const { profile, muteLists, nsfwList } =
useLoaderData() as ProfilePageLoaderResult
const navigation = useNavigation()
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.Only_NSFW) {
filter['#L'] = ['content-warning']
}
return filter
}, [filterOptions.nsfw, filterOptions.source, profile?.pubkey])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>([])
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((b) => b.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((b) => b.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((b) => b.naddr))
})
.finally(() => setIsLoading(false))
}
}, [blogfilter, blogs, fetchEvents, isLoading])
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
})
}
// 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)) {
_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) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
} else if (filterOptions.sort === SortBy.Oldest) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? a.published_at - b.published_at : 0
)
}
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 (
<>
{(isLoading || navigation.state !== 'idle') && (
<LoadingSpinner desc={'Loading...'} />
)}
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
<div className='IBMSMList IBMSMListAlt'>
{moderatedAndSortedBlogs.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
{!(page === 1 && !hasMore) && (
<Pagination
page={page}
disabledNext={!hasMore}
handlePrev={handlePrev}
handleNext={handleNext}
/>
)}
</>
)
}

147
src/pages/profile/loader.ts Normal file
View File

@ -0,0 +1,147 @@
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 { MuteLists, UserProfile } from 'types'
import { log, LogType, npubToHex } from 'utils'
export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile
isBlocked: boolean
isOwnProfile: boolean
muteLists: {
admin: MuteLists
user: MuteLists
}
nsfwList: string[]
}
export const profileRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
// Try to decode nprofile parameter
const { nprofile } = params
let profilePubkey: string | undefined
try {
// Decode if it starts with nprofile1
if (nprofile?.startsWith('nprofile1')) {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} else if (nprofile?.startsWith('npub1')) {
// Try to get hex from the npub and encode it to nprofile
const value = npubToHex(nprofile)
if (value) {
return redirect(
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: value
})
)
)
}
}
} 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
// 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 = {
profilePubkey: profilePubkey,
profile: {},
isBlocked: false,
isOwnProfile: false,
muteLists: {
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
},
nsfwList: []
}
// Check if user the user is logged in
if (userState.auth && userState.user?.pubkey) {
result.isOwnProfile = userState.user.pubkey === profilePubkey
}
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
result.isBlocked = result.muteLists.user.authors.includes(profilePubkey)
} else if (muteListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelist.',
muteListResult.reason
)
}
// 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
}

View File

@ -1,75 +0,0 @@
import { CheckboxField, InputField } from '../components/Inputs'
import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector } from '../hooks'
import '../styles/innerPage.css'
import '../styles/styles.css'
import '../styles/write.css'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Write a blog post (WIP)
</h2>
</div>
<div className='IBMSMSMBS_Write'>
<InputField
label='Title'
placeholder=''
name='title'
value=''
onChange={() => {}}
/>
<InputField
label='Body'
placeholder=''
name='body'
value=''
onChange={() => {}}
/>
<InputField
label='Featured Image URL'
placeholder=''
name='imageUrl'
inputMode='url'
value=''
onChange={() => {}}
/>
<InputField
label='Summary'
placeholder=''
name='summary'
type='textarea'
value=''
onChange={() => {}}
/>
<CheckboxField
label='This mod not safe for work (NSFW)'
name='nsfw'
isChecked={false}
handleChange={() => {}}
type='stylized'
/>
<div className='IBMSMSMBS_WriteAction'>
<button className='btn btnMain' type='button'>
Publish
</button>
</div>
</div>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

189
src/pages/write/action.tsx Normal file
View File

@ -0,0 +1,189 @@
import { NDKContextType } from 'contexts/NDKContext'
import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { getBlogPageRoute } from 'routes'
import { BlogFormErrors, BlogEventSubmitForm, BlogEventEditForm } 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<BlogEventSubmitForm | BlogEventEditForm>(
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!)
// Check if we are editing or this is a new blog
const { naddr } = params
const isEditing =
naddr && request.method === 'PUT' && isEditForm(formSubmit)
const formEdit = isEditing ? formSubmit : undefined
const currentTimeStamp = now()
// Get the existing edited fields or new ones
const uuid = isEditing && formEdit?.dTag ? formSubmit.dTag : uuidv4()
const rTag =
isEditing && formEdit?.rTag ? formEdit.rTag : window.location.host
const published_at =
isEditing && formEdit?.published_at
? formEdit.published_at
: currentTimeStamp
const aTag = `${kinds.LongFormArticle}:${hexPubkey}:${uuid}`
const tTags = formSubmit
.tags!.toLowerCase()
.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: tags
}
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: uuid,
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<BlogEventSubmitForm>
): Promise<BlogFormErrors> => {
const errors: BlogFormErrors = {}
if (!formData.title || formData.title.trim() === '') {
errors.title = 'Title field can not be empty'
}
if (
!formData.content ||
formData.content.trim() === '' ||
formData.content.trim() === '<p></p>'
) {
errors.content = 'Content field can not be empty'
}
if (!formData.summary || formData.summary.trim() === '') {
errors.summary = 'Summary field can not be empty'
}
if (!formData.tags || formData.tags.trim() === '') {
errors.tags = 'Tags field can not be empty'
}
if (!formData.image || formData.image.trim() === '') {
errors.image = 'Image url field can not be empty'
} else if (
!isValidImageUrl(formData.image) ||
!(await isReachable(formData.image))
) {
errors.image = 'Image must be a valid and reachable'
}
return errors
}
function isEditForm(
form: Partial<BlogEventSubmitForm | BlogEventEditForm>
): form is BlogEventEditForm {
return (form as BlogEventEditForm).dTag !== undefined
}

156
src/pages/write/index.tsx Normal file
View File

@ -0,0 +1,156 @@
import { useState } from 'react'
import {
Form,
useActionData,
useLoaderData,
useNavigation
} from 'react-router-dom'
import {
CheckboxFieldUncontrolled,
InputError,
InputFieldUncontrolled,
MenuBar
} from '../../components/Inputs'
import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector } from '../../hooks'
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 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<string>(sanitized)
const editor = useEditor({
content: content,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
onUpdate: ({ editor }) => {
setContent(editor.getHTML())
}
})
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
</div>
{navigation.state === 'loading' && (
<LoadingSpinner desc='Loading..' />
)}
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing blog to relays' />
)}
<Form className='IBMSMSMBS_Write' method={blog ? 'put' : 'post'}>
<InputFieldUncontrolled
label='Title'
name='title'
defaultValue={blog?.title}
error={formErrors?.title}
/>
{editor && (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
<input name='content' hidden value={content} readOnly />
</div>
)}
<InputFieldUncontrolled
label='Featured Image URL'
name='image'
inputMode='url'
defaultValue={blog?.image}
error={formErrors?.image}
/>
<InputFieldUncontrolled
label='Summary'
name='summary'
type='textarea'
defaultValue={blog?.summary}
error={formErrors?.summary}
/>
<InputFieldUncontrolled
label='Tags'
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
placeholder='Tags'
name='tags'
defaultValue={blog?.tTags?.join(', ')}
error={formErrors?.tags}
/>
<CheckboxFieldUncontrolled
label='This post is not safe for work (NSFW)'
name='nsfw'
defaultChecked={blog?.nsfw}
/>
{typeof blog?.dTag !== 'undefined' && (
<input name='dTag' hidden value={blog.dTag} readOnly />
)}
{typeof blog?.rTag !== 'undefined' && (
<input name='rTag' hidden value={blog.rTag} readOnly />
)}
{typeof blog?.published_at !== 'undefined' && (
<input
name='published_at'
hidden
value={blog.published_at}
readOnly
/>
)}
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
type='submit'
disabled={
navigation.state === 'loading' ||
navigation.state === 'submitting'
}
>
{navigation.state === 'submitting'
? 'Publishing...'
: 'Publish'}
</button>
</div>
</Form>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,21 +1,29 @@
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'
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'
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 { 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: '/',
@ -25,7 +33,10 @@ export const appRoutes = {
mods: '/mods',
mod: '/mod/:naddr',
about: '/about',
blog: '/blog',
blogs: '/blog',
blog: '/blog/:naddr',
blogEdit: '/blog/:naddr/edit',
blogReport_actionOnly: '/blog/:naddr/report',
submitMod: '/submit-mod',
editMod: '/edit-mod/:naddr',
write: '/write',
@ -48,94 +59,119 @@ 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: <Layout />,
children: [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blog,
element: <BlogsPage />
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
},
{
element: <FeedLayout />,
children: [
{
path: appRoutes.feed,
element: <FeedPage />
},
{
path: appRoutes.notifications,
element: <NotificationsPage />
}
]
},
{
path: '*',
element: <NotFoundPage />
}
]
}
])
export const routerWithNdkContext = (context: NDKContextType) =>
createBrowserRouter([
{
element: <Layout />,
children: [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blogs,
element: <BlogsPage />,
loader: blogsRouteLoader(context)
},
{
path: appRoutes.blog,
element: <BlogPage />,
loader: blogRouteLoader(context),
action: blogRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogEdit,
element: <WritePage key='edit' />,
loader: blogRouteLoader(context),
action: writeRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogReport_actionOnly,
action: blogReportRouteAction(context)
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage key='write' />,
action: writeRouteAction(context)
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />,
loader: profileRouteLoader(context)
},
{
element: <FeedLayout />,
children: [
{
path: appRoutes.feed,
element: <FeedPage />
},
{
path: appRoutes.notifications,
element: <NotificationsPage />
}
]
},
{
path: '*',
element: <NotFoundPage />
}
]
}
])

View File

@ -46,3 +46,20 @@
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;
right: 10px;
bottom: unset;
}

View File

@ -0,0 +1,18 @@
.loading::after {
content: '.';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: '.\00a0\00a0';
}
40% {
content: '..\00a0';
}
60%,
100% {
content: '...';
}
}

View File

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

42
src/types/blog.ts Normal file
View File

@ -0,0 +1,42 @@
export interface BlogForm {
title: string
content: string
image: string
summary: string
tags: string
nsfw: boolean
}
export interface BlogDetails extends BlogForm {
id: string
author: string
published_at: number
edited_at: number
rTag: string
dTag: string
aTag: string
tTags: string[]
}
export interface BlogEventSubmitForm extends Omit<BlogForm, 'nsfw'> {
nsfw: string
}
export interface BlogEventEditForm extends BlogEventSubmitForm {
dTag: string
rTag: string
published_at: string
}
export interface BlogFormErrors extends Partial<BlogEventSubmitForm> {}
export interface BlogCardDetails extends BlogDetails {
naddr: string
}
export interface BlogPageLoaderResult {
blog: Partial<BlogDetails> | undefined
latest: Partial<BlogDetails>[]
isAddedToNSFW: boolean
isBlocked: boolean
}

View File

@ -3,3 +3,4 @@ export * from './modsFilter'
export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'

View File

@ -7,3 +7,9 @@ export interface SignedEvent {
id: string
sig: string
}
export interface Addressable {
author: string
id: string
aTag: string
}

52
src/utils/blog.ts Normal file
View File

@ -0,0 +1,52 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { BlogCardDetails, BlogDetails } from 'types'
import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr'
import { kinds, nip19 } from 'nostr-tools'
export const extractBlogDetails = (event: NDKEvent): Partial<BlogDetails> => {
const dTag = getFirstTagValue(event, 'd')
// Check if the aTag exists on the blog
let aTag = getFirstTagValue(event, 'a')
// Create aTag from components if aTag is not included
if (typeof aTag === 'undefined' && event.pubkey && dTag) {
aTag = `${kinds.LongFormArticle}:${event.pubkey}:${dTag}`
}
return {
title: getFirstTagValue(event, 'title'),
content: event.content,
summary: getFirstTagValue(event, 'summary'),
image: getFirstTagValue(event, 'image'),
// 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'),
edited_at: event.created_at,
rTag: getFirstTagValue(event, 'r') || 'N/A',
dTag: dTag,
aTag: aTag,
tTags: getTagValues(event, 't') || []
}
}
export const extractBlogCardDetails = (
event: NDKEvent
): Partial<BlogCardDetails> => {
const blogDetails = extractBlogDetails(event)
return {
...blogDetails,
naddr: blogDetails.dTag
? nip19.naddrEncode({
identifier: blogDetails.dTag,
kind: kinds.LongFormArticle,
pubkey: event.pubkey
})
: undefined
}
}

View File

@ -5,3 +5,4 @@ export * from './utils'
export * from './zap'
export * from './localStorage'
export * from './consts'
export * from './blog'

View File

@ -65,6 +65,38 @@ 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 = getTagValues(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

View File

@ -146,3 +146,13 @@ export const scrollIntoView = (el: HTMLElement | null) => {
}, 100)
}
}
export const parseFormData = <T>(formData: FormData) => {
const result: Partial<T> = {}
formData.forEach(
(value, key) => ((result as Record<string, unknown>)[key] = value as string)
)
return result
}

1
src/vite-env.d.ts vendored
View File

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