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
All checks were successful
Release to Production / build_and_release (push) Successful in 48s
Reviewed-on: #124
This commit is contained in:
commit
2e0cd2378a
@ -12,3 +12,6 @@ VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd
|
||||
|
||||
# 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
|
||||
|
||||
# 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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
400
package-lock.json
generated
@ -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",
|
||||
|
12
package.json
12
package.json
@ -14,10 +14,11 @@
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"@tiptap/core": "2.6.6",
|
||||
"@tiptap/extension-link": "2.6.6",
|
||||
"@tiptap/react": "2.6.6",
|
||||
"@tiptap/starter-kit": "2.6.6",
|
||||
"@tiptap/core": "2.9.1",
|
||||
"@tiptap/extension-image": "^2.9.1",
|
||||
"@tiptap/extension-link": "2.9.1",
|
||||
"@tiptap/react": "2.9.1",
|
||||
"@tiptap/starter-kit": "2.9.1",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "1.7.3",
|
||||
"bech32": "2.0.0",
|
||||
@ -28,6 +29,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"fslightbox-react": "1.7.6",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "^14.1.3",
|
||||
"nostr-login": "1.5.2",
|
||||
"nostr-tools": "2.7.1",
|
||||
"papaparse": "5.4.1",
|
||||
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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.
|
@ -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 |
@ -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>{' '}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
42
src/components/Internal/Interactions.tsx
Normal file
42
src/components/Internal/Interactions.tsx
Normal 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>
|
||||
)
|
||||
}
|
86
src/components/Internal/PublishDetails.tsx
Normal file
86
src/components/Internal/PublishDetails.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
@ -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)}
|
@ -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,
|
||||
|
@ -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],
|
||||
|
9
src/components/Spinner.tsx
Normal file
9
src/components/Spinner.tsx
Normal 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>
|
@ -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>
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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'
|
||||
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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
264
src/pages/blog/action.ts
Normal 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
323
src/pages/blog/index.tsx
Normal 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
233
src/pages/blog/loader.ts
Normal 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
92
src/pages/blog/report.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
146
src/pages/blog/reportAction.ts
Normal file
146
src/pages/blog/reportAction.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
208
src/pages/blogs/index.tsx
Normal 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
68
src/pages/blogs/loader.ts
Normal 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 []
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
147
src/pages/profile/loader.ts
Normal 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
|
||||
}
|
@ -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
189
src/pages/write/action.tsx
Normal 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
156
src/pages/write/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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,10 +59,14 @@ 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([
|
||||
export const routerWithNdkContext = (context: NDKContextType) =>
|
||||
createBrowserRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
@ -79,9 +94,28 @@ export const router = createBrowserRouter([
|
||||
path: appRoutes.about,
|
||||
element: <AboutPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.blogs,
|
||||
element: <BlogsPage />,
|
||||
loader: blogsRouteLoader(context)
|
||||
},
|
||||
{
|
||||
path: appRoutes.blog,
|
||||
element: <BlogsPage />
|
||||
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,
|
||||
@ -93,7 +127,8 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: appRoutes.write,
|
||||
element: <WritePage />
|
||||
element: <WritePage key='write' />,
|
||||
action: writeRouteAction(context)
|
||||
},
|
||||
{
|
||||
path: appRoutes.search,
|
||||
@ -117,7 +152,8 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: appRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
element: <ProfilePage />,
|
||||
loader: profileRouteLoader(context)
|
||||
},
|
||||
{
|
||||
element: <FeedLayout />,
|
||||
|
@ -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;
|
||||
}
|
||||
|
18
src/styles/dotsSpinner.module.scss
Normal file
18
src/styles/dotsSpinner.module.scss
Normal 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: '...';
|
||||
}
|
||||
}
|
@ -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
42
src/types/blog.ts
Normal 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
|
||||
}
|
@ -3,3 +3,4 @@ export * from './modsFilter'
|
||||
export * from './nostr'
|
||||
export * from './user'
|
||||
export * from './zap'
|
||||
export * from './blog'
|
||||
|
@ -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
52
src/utils/blog.ts
Normal 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
|
||||
}
|
||||
}
|
@ -5,3 +5,4 @@ export * from './utils'
|
||||
export * from './zap'
|
||||
export * from './localStorage'
|
||||
export * from './consts'
|
||||
export * from './blog'
|
||||
|
@ -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
|
||||
|
@ -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
1
src/vite-env.d.ts
vendored
@ -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...
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user