diff --git a/.env.example b/.env.example index e6b55e6..83ddd81 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,7 @@ VITE_REPORTING_NPUB= VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png # if there's no image, or if the image breaks somewhere down the line, then it should default to this image -VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png \ No newline at end of file +VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png + +# A comma separated list of npubs, this list is used to fetch just the posts from the admin +VITE_BLOG_NPUBS= diff --git a/package-lock.json b/package-lock.json index 2666d9c..19b9ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a3973cb..b356c8a 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "@nostr-dev-kit/ndk": "2.10.0", "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@reduxjs/toolkit": "2.2.6", - "@tiptap/core": "2.6.6", - "@tiptap/extension-link": "2.6.6", - "@tiptap/react": "2.6.6", - "@tiptap/starter-kit": "2.6.6", + "@tiptap/core": "2.9.1", + "@tiptap/extension-image": "^2.9.1", + "@tiptap/extension-link": "2.9.1", + "@tiptap/react": "2.9.1", + "@tiptap/starter-kit": "2.9.1", "@types/react-helmet": "^6.1.11", "axios": "1.7.3", "bech32": "2.0.0", @@ -28,6 +29,7 @@ "file-saver": "2.0.5", "fslightbox-react": "1.7.6", "lodash": "4.17.21", + "marked": "^14.1.3", "nostr-login": "1.5.2", "nostr-tools": "2.7.1", "papaparse": "5.4.1", @@ -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", diff --git a/src/App.tsx b/src/App.tsx index fe0ccd1..95183ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ import { RouterProvider } from 'react-router-dom' import { useEffect } from 'react' -import { router } from 'routes' +import { routerWithNdkContext } from 'routes' +import { useNDKContext } from 'hooks' import './styles/styles.css' function App() { + const ndkContext = useNDKContext() + useEffect(() => { // Find the element with id 'root' const rootElement = document.getElementById('root') @@ -21,7 +24,7 @@ function App() { } }, []) - return + return } export default App diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index e2065b7..e2d52a9 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -1,37 +1,33 @@ +import { Link } from 'react-router-dom' +import { BlogCardDetails } from 'types' +import { getBlogPageRoute } from 'routes' import '../styles/cardBlogs.css' +import placeholder from '../assets/img/DEGMods Placeholder Img.png' -type BlogCardProps = { - backgroundLink: string -} +type BlogCardProps = Partial + +export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => { + if (!naddr) return null -export const BlogCard = ({ backgroundLink }: BlogCardProps) => { return ( - +
-
-

- This is a blog title, the best blog title in the world! -

+
+

{title}

+ {nsfw && ( +
+

NSFW

+
+ )}
-
{' '} -
+
+ ) } diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index e40cc91..38d48af 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -1,4 +1,5 @@ import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' import { Editor, EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import React, { useEffect } from 'react' @@ -127,7 +128,15 @@ type RichTextEditorProps = { const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => { const editor = useEditor({ - extensions: [StarterKit, Link], + extensions: [ + StarterKit, + Link, + Image.configure({ + HTMLAttributes: { + class: 'IBMSMSMBSSPostImg' + } + }) + ], onUpdate: ({ editor }) => { // Update the state when the editor content changes updateContent(editor.getHTML()) @@ -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} ) + +interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> { + label: string + description?: string + error?: string +} +/** + * Uncontrolled input component with design classes, label, description and error support + * + * Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>} + * @param label + * @param description + * @param error + * + * @see {@link React.ComponentProps<'input'>} + */ +export const InputFieldUncontrolled = ({ + label, + description, + error, + ...rest +}: InputFieldUncontrolledProps) => ( +
+ + {description &&

{description}

} + + {error && } +
+) + +interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> { + label: string +} + +export const CheckboxFieldUncontrolled = ({ + label, + ...rest +}: CheckboxFieldUncontrolledProps) => ( +
+ + +
+) diff --git a/src/components/Internal/Interactions.tsx b/src/components/Internal/Interactions.tsx new file mode 100644 index 0000000..534c910 --- /dev/null +++ b/src/components/Internal/Interactions.tsx @@ -0,0 +1,42 @@ +import { Addressable } from 'types' +import { abbreviateNumber } from 'utils' +import { Zap } from './Zap' +import { Reactions } from './Reactions' + +type InteractionsProps = { + addressable: Addressable + commentCount: number +} + +export const Interactions = ({ + addressable, + commentCount +}: InteractionsProps) => { + return ( +
+
+ +
+
+ + + +
+

+ {abbreviateNumber(commentCount)} +

+
+
+ + +
+
+ ) +} diff --git a/src/components/Internal/PublishDetails.tsx b/src/components/Internal/PublishDetails.tsx new file mode 100644 index 0000000..88cef78 --- /dev/null +++ b/src/components/Internal/PublishDetails.tsx @@ -0,0 +1,86 @@ +import { formatDate } from 'date-fns' + +type PublishDetailsProps = { + published_at: number + edited_at: number + site: string +} + +export const PublishDetails = ({ + published_at, + edited_at, + site +}: PublishDetailsProps) => { + return ( +
+
+
+ + + +

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

+
+
+ + + +

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

+
+ + + + +

{site}

+
+
+
+ ) +} diff --git a/src/pages/mod/internal/reactions/index.tsx b/src/components/Internal/Reactions.tsx similarity index 92% rename from src/pages/mod/internal/reactions/index.tsx rename to src/components/Internal/Reactions.tsx index d50a787..4e69f13 100644 --- a/src/pages/mod/internal/reactions/index.tsx +++ b/src/components/Internal/Reactions.tsx @@ -1,11 +1,11 @@ import { useReactions } from 'hooks' -import { ModDetails } from 'types' +import { Addressable } from 'types' type ReactionsProps = { - modDetails: ModDetails + addressable: Addressable } -export const Reactions = ({ modDetails }: ReactionsProps) => { +export const Reactions = ({ addressable }: ReactionsProps) => { const { isDataLoaded, likesCount, @@ -14,9 +14,9 @@ export const Reactions = ({ modDetails }: ReactionsProps) => { hasReactedPositively, hasReactedNegatively } = useReactions({ - pubkey: modDetails.author, - eTag: modDetails.id, - aTag: modDetails.aTag + pubkey: addressable.author, + eTag: addressable.id, + aTag: addressable.aTag }) if (!isDataLoaded) return null diff --git a/src/pages/mod/internal/zap/index.tsx b/src/components/Internal/Zap.tsx similarity index 88% rename from src/pages/mod/internal/zap/index.tsx rename to src/components/Internal/Zap.tsx index 996c8d2..0c5cb7a 100644 --- a/src/pages/mod/internal/zap/index.tsx +++ b/src/components/Internal/Zap.tsx @@ -7,14 +7,14 @@ import { } from 'hooks' import { useState } from 'react' import { toast } from 'react-toastify' -import { ModDetails } from 'types' +import { Addressable } from 'types' import { abbreviateNumber } from 'utils' type ZapProps = { - modDetails: ModDetails + addressable: Addressable } -export const Zap = ({ modDetails }: ZapProps) => { +export const Zap = ({ addressable }: ZapProps) => { const [isOpen, setIsOpen] = useState(false) const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [hasZapped, setHasZapped] = useState(false) @@ -26,9 +26,9 @@ export const Zap = ({ modDetails }: ZapProps) => { useDidMount(() => { getTotalZapAmount( - modDetails.author, - modDetails.id, - modDetails.aTag, + addressable.author, + addressable.id, + addressable.aTag, userState.user?.pubkey as string ) .then((res) => { @@ -70,9 +70,9 @@ export const Zap = ({ modDetails }: ZapProps) => { {isOpen && ( setIsOpen(false)} diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index 6abd397..c54b300 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -12,7 +12,7 @@ import { useComments } from 'hooks/useComments' export const ModCard = React.memo((props: ModDetails) => { const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [commentCount, setCommentCount] = useState(0) - const { commentEvents } = useComments(props) + const { commentEvents } = useComments(props.author, props.aTag) const { likesCount, disLikesCount } = useReactions({ pubkey: props.author, eTag: props.id, diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index c3f5a02..7411c47 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -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], diff --git a/src/pages/mod/internal/comment/index.tsx b/src/components/comment/index.tsx similarity index 97% rename from src/pages/mod/internal/comment/index.tsx rename to src/components/comment/index.tsx index f238524..8d7fd93 100644 --- a/src/pages/mod/internal/comment/index.tsx +++ b/src/components/comment/index.tsx @@ -21,9 +21,9 @@ import { Link } from 'react-router-dom' import { toast } from 'react-toastify' import { getProfilePageRoute } from 'routes' import { + Addressable, CommentEvent, CommentEventStatus, - ModDetails, UserProfile } from 'types/index.ts' import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils' @@ -44,13 +44,16 @@ type FilterOptions = { } type Props = { - modDetails: ModDetails + addressable: Addressable setCommentCount: Dispatch> } -export const Comments = ({ modDetails, setCommentCount }: Props) => { +export const Comments = ({ addressable, setCommentCount }: Props) => { const { ndk, publish } = useNDKContext() - const { commentEvents, setCommentEvents } = useComments(modDetails) + const { commentEvents, setCommentEvents } = useComments( + addressable.author, + addressable.aTag + ) const [filterOptions, setFilterOptions] = useState({ sort: SortByEnum.Latest, author: AuthorFilterEnum.All_Comments @@ -84,9 +87,9 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { kind: kinds.ShortTextNote, created_at: now(), tags: [ - ['e', modDetails.id], - ['a', modDetails.aTag], - ['p', modDetails.author] + ['e', addressable.id], + ['a', addressable.aTag], + ['p', addressable.author] ] } @@ -176,7 +179,7 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { let filteredComments = commentEvents if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { filteredComments = filteredComments.filter( - (comment) => comment.pubkey === modDetails.author + (comment) => comment.pubkey === addressable.author ) } @@ -187,7 +190,7 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { } return filteredComments - }, [commentEvents, filterOptions, modDetails.author]) + }, [commentEvents, filterOptions, addressable.author]) return (
diff --git a/src/constants.ts b/src/constants.ts index e2838b0..d4a56d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,7 +20,8 @@ export const LANDING_PAGE_DATA = { 'Cyberpunk 2077', 'ELDEN RING', 'The Coffin of Andy and Leyley' - ] + ], + featuredBlogPosts: [] } // we use this object to check if a user has reacted positively or negatively to a post // reactions are kind 7 events and their content is either emoji icon or emoji shortcode @@ -112,7 +113,8 @@ export const REACTIONS = { export const MAX_MODS_PER_PAGE = 10 export const MAX_GAMES_PER_PAGE = 10 - // todo: add game and mod fallback image here export const FALLBACK_PROFILE_IMAGE = 'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png' + +export const PROFILE_BLOG_FILTER_LIMIT = 20 diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index b76f3f2..23f672c 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -32,7 +32,7 @@ type FetchModsOptions = { author?: string } -interface NDKContextType { +export interface NDKContextType { ndk: NDK fetchMods: (opts: FetchModsOptions) => Promise fetchEvents: (filter: NDKFilter) => Promise diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index fb57eb8..8b107bc 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -7,22 +7,30 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { useEffect, useState } from 'react' -import { CommentEvent, ModDetails, UserRelaysType } from 'types' +import { CommentEvent, UserRelaysType } from 'types' import { log, LogType, timeout } from 'utils' import { useNDKContext } from './useNDKContext' -export const useComments = (mod: ModDetails) => { +export const useComments = ( + author: string | undefined, + aTag: string | undefined +) => { const { ndk } = useNDKContext() const [commentEvents, setCommentEvents] = useState([]) useEffect(() => { + if (!(author && aTag)) { + // Author and aTag are required + return + } + let subscription: NDKSubscription // Define the subscription variable here for cleanup const setupSubscription = async () => { // Find the mod author's relays. const authorReadRelays = await Promise.race([ - getRelayListForUser(mod.author, ndk), + getRelayListForUser(author, ndk), timeout(10 * 1000) // add a 10 sec timeout ]) .then((ndkRelayList) => { @@ -33,7 +41,7 @@ export const useComments = (mod: ModDetails) => { log( true, LogType.Error, - `An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`, + `An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`, err ) return [] as string[] @@ -41,7 +49,7 @@ export const useComments = (mod: ModDetails) => { const filter: NDKFilter = { kinds: [NDKKind.Text], - '#a': [mod.aTag] + '#a': [aTag] } const relayUrls = new Set() @@ -92,7 +100,7 @@ export const useComments = (mod: ModDetails) => { subscription.stop() } } - }, [mod.aTag, mod.author, ndk]) + }, [aTag, author, ndk]) return { commentEvents, diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts index f7383df..20acf27 100644 --- a/src/hooks/useNDKContext.ts +++ b/src/hooks/useNDKContext.ts @@ -1,4 +1,4 @@ -import { NDKContext } from 'contexts/NDKContext' +import { NDKContext, NDKContextType } from 'contexts/NDKContext' import { useContext } from 'react' export const useNDKContext = () => { @@ -9,5 +9,5 @@ export const useNDKContext = () => { 'NDKContext should not be used in out component tree hierarchy' ) - return { ...ndkContext } + return { ...ndkContext } as NDKContextType } diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 011b7cf..18d034f 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -212,7 +212,7 @@ export const Header = () => { About Blog diff --git a/src/layout/index.tsx b/src/layout/index.tsx index a644ced..cacabd4 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -1,4 +1,4 @@ -import { Outlet } from 'react-router-dom' +import { Outlet, ScrollRestoration } from 'react-router-dom' import { Footer } from './footer' import { Header } from './header' import { SocialNav } from './socialNav' @@ -12,6 +12,7 @@ export const Layout = () => {
+ ) } diff --git a/src/pages/blog/action.ts b/src/pages/blog/action.ts new file mode 100644 index 0000000..696fcdd --- /dev/null +++ b/src/pages/blog/action.ts @@ -0,0 +1,264 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs } from 'react-router-dom' +import { toast } from 'react-toastify' +import { store } from 'store' +import { UserRelaysType } from 'types' +import { log, LogType, now, signAndPublish } from 'utils' + +export const blogRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return null + } + + // Decode author from naddr + let aTag: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { identifier, kind, pubkey } = decoded.data + aTag = `${kind}:${pubkey}:${identifier}` + } catch (error) { + log(true, LogType.Error, 'Failed to decode naddr') + return null + } + + if (!aTag) { + log(true, LogType.Error, 'Missing #a Tag') + return null + } + + const userState = store.getState().user + let hexPubkey: string + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!hexPubkey) { + toast.error('Failed to get the pubkey') + return null + } + + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + + const handleBlock = async () => { + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Blog reference is already in user's mute list`) + return null + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + + const handleUnblock = async () => { + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + if (!muteListEvent) { + toast.error(`Couldn't get user's mute list event from relays`) + return null + } + + const tags = muteListEvent.tags + const unsignedEvent: UnsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + const handleAddNSFW = async () => { + const filter: NDKFilter = { + kinds: [kinds.Curationsets], + authors: [hexPubkey], + '#d': ['nsfw'] + } + + const nsfwListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (nsfwListEvent) { + const tags = nsfwListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Blog reference is already in user's nsfw list`) + return null + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: kinds.Curationsets, + content: nsfwListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Curationsets, + content: '', + created_at: now(), + tags: [ + ['a', aTag], + ['d', 'nsfw'] + ] + } + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's nsfw list") + } + return null + } + const handleRemoveNSFW = async () => { + const filter: NDKFilter = { + kinds: [kinds.Curationsets], + authors: [hexPubkey], + '#d': ['nsfw'] + } + + const nsfwListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + if (!nsfwListEvent) { + toast.error(`Couldn't get nsfw list event from relays`) + return null + } + + const tags = nsfwListEvent.tags + + const unsignedEvent: UnsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: kinds.Curationsets, + content: nsfwListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's nsfw list") + } + return null + } + + const requestData = (await request.json()) as { + intent: 'nsfw' | 'block' + value: boolean + } + + switch (requestData.intent) { + case 'block': + await (requestData.value ? handleBlock() : handleUnblock()) + break + + case 'nsfw': + if (!isAdmin) { + log(true, LogType.Error, 'Unable to update NSFW list. No permission') + return null + } + await (requestData.value ? handleAddNSFW() : handleRemoveNSFW()) + break + + default: + log(true, LogType.Error, 'Missing intent for blog action') + break + } + + return null + } diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx new file mode 100644 index 0000000..56d2b30 --- /dev/null +++ b/src/pages/blog/index.tsx @@ -0,0 +1,324 @@ +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 ( +
+
+
+
+ {!blog ? ( + + ) : ( + <> +
+
+
+ +
+
+
+

+ {blog.title} +

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

NSFW

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

+ Latest blog posts +

+
+ {latest.map((b) => ( + + ))} +
+
+
+ )} +
+ +
+
+
+ {navigation.state !== 'idle' && ( + + )} + {showReportPopUp && ( + setShowReportPopUp(false)} /> + )} + + )} + {!!blog?.author && } +
+
+
+
+ ) +} diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts new file mode 100644 index 0000000..aadddb0 --- /dev/null +++ b/src/pages/blog/loader.ts @@ -0,0 +1,177 @@ +import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19 } from 'nostr-tools' +import { LoaderFunctionArgs, redirect } from 'react-router-dom' +import { toast } from 'react-toastify' +import { appRoutes } from 'routes' +import { store } from 'store' +import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' +import { + DEFAULT_FILTER_OPTIONS, + getLocalStorageItem, + log, + LogType +} from 'utils' +import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog' + +export const blogRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return redirect(appRoutes.blogs) + } + + // Decode author from naddr + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { pubkey } = decoded.data + + try { + // Get the filter with #a from naddr for the main blog content + const filter = filterForEventsTaggingId(naddr) + if (!filter) { + log(true, LogType.Error, 'Unable to create filter from blog naddr.') + return redirect(appRoutes.blogs) + } + // Update kinds to make sure we fetch correct event kind + filter.kinds = [kinds.LongFormArticle] + + const userState = store.getState().user + + // Get the blog filter options for latest blogs + const filterOptions = JSON.parse( + getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS) + ) as FilterOptions + + // Fetch 4 in case the current blog is included in the latest + const latestModsFilter: NDKFilter = { + authors: [pubkey], + kinds: [kinds.LongFormArticle], + limit: 4 + } + // Add source filter + if (filterOptions.source === window.location.host) { + latestModsFilter['#r'] = [filterOptions.source] + } + // Filter by NSFW tag + // NSFWFilter.Show_NSFW -> filter not needed + // NSFWFilter.Only_NSFW -> true + // NSFWFilter.Hide_NSFW -> false + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + latestModsFilter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + // Parallel fetch blog event, latest events, mute, and nsfw lists in parallel + const settled = await Promise.allSettled([ + ndkContext.fetchEvent(filter), + ndkContext.fetchEvents(latestModsFilter), + ndkContext.getMuteLists(userState?.user?.pubkey as string), + 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 + ) + } + + // Check the lateast blog events + const fetchEventsResult = settled[1] + if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { + // Extract the blog card details from the events + result.latest = fetchEventsResult.value + .map(extractBlogCardDetails) + .filter((b) => b.id !== result.blog?.id) // Filter out current blog if present + .slice(0, 3) // Take only three + } else if (fetchEventsResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the latest blog events.', + fetchEventsResult.reason + ) + } + + const muteList = settled[2] + if (muteList.status === 'fulfilled' && muteList.value) { + if (muteList && muteList.value) { + if (result.blog && result.blog.aTag) { + if ( + muteList.value.admin.replaceableEvents.includes( + result.blog.aTag + ) || + muteList.value.user.replaceableEvents.includes(result.blog.aTag) + ) { + result.isBlocked = true + } + } + } + } else if (muteList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching mute list', muteList.reason) + } + + const nsfwList = settled[3] + if (nsfwList.status === 'fulfilled' && nsfwList.value) { + // Check if the blog is marked as NSFW + // Mark it as NSFW only if it's missing the tag + if (result.blog) { + const isMissingNsfwTag = + !result.blog.nsfw && + result.blog.aTag && + nsfwList.value.includes(result.blog.aTag) + + if (isMissingNsfwTag) { + result.blog.nsfw = true + } + + if (result.blog.aTag && nsfwList.value.includes(result.blog.aTag)) { + result.isAddedToNSFW = true + } + } + + // Check if the the latest blogs too + result.latest = result.latest.map((b) => { + if (b) { + const isMissingNsfwTag = + !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) + + if (isMissingNsfwTag) { + b.nsfw = true + } + } + return b + }) + } else if (nsfwList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) + } + + return result + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + toast.error('An error occurred in fetching blog details from relays') + return redirect(appRoutes.blogs) + } + } diff --git a/src/pages/blog/report.tsx b/src/pages/blog/report.tsx new file mode 100644 index 0000000..b3f6f14 --- /dev/null +++ b/src/pages/blog/report.tsx @@ -0,0 +1,92 @@ +import { useFetcher } from 'react-router-dom' +import { CheckboxFieldUncontrolled } from 'components/Inputs' +import { useEffect } from 'react' + +type ReportPopupProps = { + handleClose: () => void +} + +const BLOG_REPORT_REASONS = [ + { label: 'Actually CP', key: 'actuallyCP' }, + { label: 'Spam', key: 'spam' }, + { label: 'Scam', key: 'scam' }, + { label: 'Malware', key: 'malware' }, + { label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' }, + { label: 'Other', key: 'otherReason' } +] + +export const ReportPopup = ({ handleClose }: ReportPopupProps) => { + const fetcher = useFetcher() + + // Close automatically if action succeeds + useEffect(() => { + if (fetcher.data) { + const { isSent } = fetcher.data + console.log(fetcher.data) + if (isSent) { + handleClose() + } + } + }, [fetcher, handleClose]) + + return ( + <> +
+
+
+
+
+
+

Report Post

+
+
+ + + +
+
+
+ +
+ + {BLOG_REPORT_REASONS.map((r) => ( + + ))} +
+ +
+
+
+
+
+
+ + ) +} diff --git a/src/pages/blog/reportAction.ts b/src/pages/blog/reportAction.ts new file mode 100644 index 0000000..c39e593 --- /dev/null +++ b/src/pages/blog/reportAction.ts @@ -0,0 +1,146 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs } from 'react-router-dom' +import { toast } from 'react-toastify' +import { store } from 'store' +import { UserRelaysType } from 'types' +import { + log, + LogType, + now, + npubToHex, + parseFormData, + sendDMUsingRandomKey, + signAndPublish +} from 'utils' + +export const blogReportRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const requestData = await request.formData() + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return false + } + + // Decode author from naddr + let aTag: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { identifier, kind, pubkey } = decoded.data + aTag = `${kind}:${pubkey}:${identifier}` + } catch (error) { + log(true, LogType.Error, 'Failed to decode naddr') + return false + } + + if (!aTag) { + log(true, LogType.Error, 'Missing #a Tag') + return false + } + + const userState = store.getState().user + let hexPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } + + const reportingNpub = import.meta.env.VITE_REPORTING_NPUB + const reportingPubkey = npubToHex(reportingNpub) + + // Parse the the data + const formSubmit = parseFormData(requestData) + + const selectedOptionsCount = Object.values(formSubmit).filter( + (checked) => checked === 'on' + ).length + if (selectedOptionsCount === 0) { + toast.error('At least one option should be checked!') + return false + } + + if (reportingPubkey === hexPubkey) { + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + if (muteListEvent) { + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + if (alreadyExists) { + toast.warn(`Blog reference is already in user's mute list`) + return false + } + tags.push(['a', aTag]) + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + try { + hexPubkey = await window.nostr?.getPublicKey() + } catch (error) { + log( + true, + LogType.Error, + 'Could not get pubkey for reporting blog!', + error + ) + toast.error('Could not get pubkey for reporting blog!') + return false + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + return { isSent: isUpdated } + } else { + const href = window.location.href + let message = `I'd like to report ${href} due to following reasons:\n` + Object.entries(formSubmit).forEach(([key, value]) => { + if (value === 'on') { + message += `* ${key}\n` + } + }) + try { + const isSent = await sendDMUsingRandomKey( + message, + reportingPubkey!, + ndkContext.ndk, + ndkContext.publish + ) + return { isSent: isSent } + } catch (error) { + log(true, LogType.Error, 'Failed to send a blog report', error) + return false + } + } + } diff --git a/src/pages/blogs.tsx b/src/pages/blogs.tsx deleted file mode 100644 index 822aa61..0000000 --- a/src/pages/blogs.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { BlogCard } from '../components/BlogCard' -import '../styles/filters.css' -import '../styles/pagination.css' -import '../styles/search.css' -import '../styles/styles.css' -import placeholder from '../assets/img/DEGMods Placeholder Img.png' - -export const BlogsPage = () => { - return ( -
-
-
-
-
-
-

Blogs (WIP)

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

Blogs

+
+ +
+
+ +
+
+
+
+ +
+ {Object.values(SortBy).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + sort: item + })) + } + > + {item} +
+ ))} +
+
+
+
+
+ +
+ {Object.values(NSFWFilter).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + nsfw: item + })) + } + > + {item} +
+ ))} +
+
+
+
+
+ +
+
+ {currentMods && + currentMods.map((b) => )} +
+
+ + {totalPages > 1 && ( + + )} +
+
+
+ ) +} diff --git a/src/pages/blogs/loader.ts b/src/pages/blogs/loader.ts new file mode 100644 index 0000000..f2ab84d --- /dev/null +++ b/src/pages/blogs/loader.ts @@ -0,0 +1,35 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds } from 'nostr-tools' +import { log, LogType, npubToHex } from 'utils' +import { extractBlogCardDetails } from 'utils/blog' + +export const blogsRouteLoader = (ndkContext: NDKContextType) => async () => { + try { + const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') + const blogHexkeys = blogNpubs + .map(npubToHex) + .filter((hexkey) => hexkey !== null) + + const filter: NDKFilter = { + authors: blogHexkeys, + kinds: [kinds.LongFormArticle] + } + const events = await ndkContext.fetchEvents(filter) + + if (!events) { + log(true, LogType.Error, 'Unable to fetch the blog events.') + return null + } + + return events.map(extractBlogCardDetails).filter((e) => e.naddr) + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + return null + } +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5c15e25..4b6c7d2 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,6 +1,6 @@ -import { nip19 } from 'nostr-tools' +import { kinds, nip19 } from 'nostr-tools' import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' import { Swiper, SwiperSlide } from 'swiper/react' import { BlogCard } from '../components/BlogCard' @@ -10,24 +10,35 @@ import { LANDING_PAGE_DATA } 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' import '../styles/styles.css' // Import Swiper styles -import { NDKFilter } from '@nostr-dev-kit/ndk' +import { + filterForEventsTaggingId, + NDKEvent, + NDKFilter +} from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' -import placeholder from '../assets/img/DEGMods Placeholder Img.png' export const HomePage = () => { const navigate = useNavigate() @@ -114,27 +125,7 @@ export const HomePage = () => {
-
-
-

Blog Posts (WIP)

-
-
- - - - -
- - -
+ @@ -327,3 +318,128 @@ const Spinner = () => { ) } + +const DisplayLatestBlogs = () => { + const [blogs, setBlogs] = useState[]>() + const { fetchEvents } = useNDKContext() + const [filterOptions] = useLocalStorage('filter-blog-curated', { + sort: SortBy.Latest, + nsfw: NSFWFilter.Hide_NSFW + }) + useDidMount(() => { + const fetchBlogs = async () => { + try { + // Show maximum of 4 blog posts + // 2 should be featured and the most recent 2 from blog npubs + // Populate the filter from known naddr (constants.ts) + const filters: NDKFilter[] = [] + for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) { + try { + const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i] + const filterId = filterForEventsTaggingId(naddr) + if (filterId) { + filters.push(filterId) + } + } catch (error) { + // Silently ignore + } + } + // Create a single filter based on multiple #a's + const filter = filters.reduce( + (filter, id) => { + const a = id['#a'] + if (a) { + filter['#a']?.push(a[0]) + } + return filter + }, + { + '#a': [] + } as NDKFilter + ) + // Prepare filter for the latest + const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') + const blogHexkeys = blogNpubs + .map(npubToHex) + .filter((hexkey) => hexkey !== null) + + // We fetch 4 posts in case of duplicates (from featured) + const latestFilter: NDKFilter = { + authors: blogHexkeys, + kinds: [kinds.LongFormArticle], + limit: 4 + } + + // Filter by NSFW tag + // NSFWFilter.Show_NSFW -> filter not needed + // NSFWFilter.Only_NSFW -> true + // NSFWFilter.Hide_NSFW -> false + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + latestFilter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + const results = await Promise.allSettled([ + fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), + fetchEvents(latestFilter) + ]) + + const events: NDKEvent[] = [] + // Get featured blogs posts result + results.forEach((r) => { + // Add events from both promises to the array + if (r.status === 'fulfilled' && r.value) { + events.push(...r.value) + } + }) + + // Remove duplicates + const unique = Array.from( + events + .reduce((map, obj) => { + map.set(obj.id, obj) + return map + }, new Map()) + .values() + ) + const latest = unique.slice(0, 4) + + setBlogs(latest.map(extractBlogCardDetails)) + } catch (error) { + log( + true, + LogType.Error, + 'An error occurred in fetching blog details from relays', + error + ) + return null + } + } + + fetchBlogs() + }) + + return ( +
+
+

Blog Posts

+
+
+ {blogs?.map((b) => ( + + ))} +
+ +
+ + View All + +
+
+ ) +} diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 5aa9bf9..dc34ca6 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -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} /> { )} -
-
-

- Creator's Blog Posts (WIP) -

-
- - - -
-
-
+
@@ -972,126 +964,6 @@ const Body = ({ ) } -type InteractionsProps = { - modDetails: ModDetails - commentCount: number -} - -const Interactions = ({ modDetails, commentCount }: InteractionsProps) => { - return ( - - ) -} - -type PublishDetailsProps = { - published_at: number - edited_at: number - site: string -} - -const PublishDetails = ({ - published_at, - edited_at, - site -}: PublishDetailsProps) => { - return ( -
-
-
- - - -

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

-
-
- - - -

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

-
- - - - -

{site}

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

Creator's Blog Posts

+
+ {blogs?.map((b) => ( + + ))} +
+
+
+ ) +} diff --git a/src/pages/profile.tsx b/src/pages/profile/index.tsx similarity index 79% rename from src/pages/profile.tsx rename to src/pages/profile/index.tsx index 84e2293..584c34f 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile/index.tsx @@ -5,7 +5,7 @@ import { ModFilter } from 'components/ModsFilter' import { Pagination } from 'components/Pagination' import { ProfileSection } from 'components/ProfileSection' import { Tabs } from 'components/Tabs' -import { MOD_FILTER_LIMIT } from '../constants' +import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { useAppSelector, useFilteredMods, @@ -14,15 +14,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, nip19, UnsignedEvent } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes, getProfilePageRoute } from 'routes' -import { FilterOptions, ModDetails, UserRelaysType } from 'types' +import { + BlogCardDetails, + FilterOptions, + ModDetails, + ModeratedFilter, + NSFWFilter, + SortBy, + UserRelaysType +} from 'types' import { copyTextToClipboard, DEFAULT_FILTER_OPTIONS, + extractBlogCardDetails, log, LogType, now, @@ -32,9 +41,15 @@ import { signAndPublish } from 'utils' import { CheckboxField } from 'components/Inputs' -import { useProfile } from 'hooks/useProfile' +import { ProfilePageLoaderResult } from './loader' +import { BlogCard } from 'components/BlogCard' export const ProfilePage = () => { + const { + profile, + isBlocked: _isBlocked, + isOwnProfile + } = useLoaderData() as ProfilePageLoaderResult // Try to decode nprofile parameter const { nprofile } = useParams() let profilePubkey: string | undefined @@ -51,46 +66,14 @@ export const ProfilePage = () => { const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const userState = useAppSelector((state) => state.user) - const isOwnProfile = - userState.auth && userState.user?.pubkey === profilePubkey const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const profile = useProfile(profilePubkey) - const displayName = profile?.displayName || profile?.name || '[name not set up]' const [showReportPopUp, setShowReportPopUp] = useState(false) - const [isBlocked, setIsBlocked] = useState(false) - useEffect(() => { - if (userState.auth && userState.user?.pubkey) { - const userHexKey = userState.user.pubkey as string - - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [userHexKey] - } - - fetchEventFromUserRelays( - muteListFilter, - userHexKey, - UserRelaysType.Write - ).then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const blocked = - tags.findIndex( - (item) => item[0] === 'p' && item[1] === profilePubkey - ) !== -1 - - setIsBlocked(blocked) - } - }) - } - }, [userState, profilePubkey, fetchEventFromUserRelays]) - + const [isBlocked, setIsBlocked] = useState(_isBlocked) const handleBlock = async () => { if (!profilePubkey) { toast.error(`Something went wrong. Unable to find reported user's pubkey`) @@ -482,7 +465,7 @@ export const ProfilePage = () => { )} - {tab === 1 && <>WIP} + {tab === 1 && } {tab === 2 && <>WIP} @@ -703,3 +686,183 @@ const ReportUserPopup = ({ ) } + +const ProfileTabBlogs = () => { + const { profile, muteLists, nsfwList } = + useLoaderData() as ProfilePageLoaderResult + const { fetchEvents } = useNDKContext() + const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) + const [isLoading, setIsLoading] = useState(true) + const blogfilter: NDKFilter = useMemo(() => { + const filter: NDKFilter = { + authors: [profile?.pubkey as string], + kinds: [kinds.LongFormArticle] + } + + const host = window.location.host + if (filterOptions.source === host) { + filter['#r'] = [host] + } + + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + filter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + return filter + }, [filterOptions.nsfw, filterOptions.source, profile?.pubkey]) + + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [blogs, setBlogs] = useState[]>([]) + useEffect(() => { + if (profile) { + // Initial blog fetch, go beyond limit to check for next + const filter: NDKFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1 + } + fetchEvents(filter) + .then((events) => { + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [blogfilter, fetchEvents, profile]) + + const handleNext = useCallback(() => { + if (isLoading) return + + const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined + if (last?.published_at) { + const until = last?.published_at - 1 + const nextFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1, + until + } + setIsLoading(true) + fetchEvents(nextFilter) + .then((events) => { + const nextBlogs = events.map(extractBlogCardDetails) + setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) + setPage((prev) => prev + 1) + setBlogs( + nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) + ) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const handlePrev = useCallback(() => { + if (isLoading) return + + const first = blogs.length > 0 ? blogs[0] : undefined + if (first?.published_at) { + const since = first.published_at + 1 + const prevFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT, + since + } + setIsLoading(true) + fetchEvents(prevFilter) + .then((events) => { + setHasMore(true) + setPage((prev) => prev - 1) + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const userState = useAppSelector((state) => state.user) + const moderatedAndSortedBlogs = useMemo(() => { + let _blogs = blogs || [] + const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + const isOwner = + userState.user?.pubkey && userState.user.pubkey === profile?.pubkey + const isUnmoderatedFully = + filterOptions.moderated === ModeratedFilter.Unmoderated_Fully + + // Add nsfw tag to blogs included in nsfwList + if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) { + _blogs = _blogs.map((b) => { + return !b.nsfw && b.aTag && nsfwList.includes(b.aTag) + ? { ...b, nsfw: true } + : b + }) + } + + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" + // Allow "Unmoderated Fully" when author visits own profile + if (!((isAdmin || isOwner) && isUnmoderatedFully)) { + _blogs = _blogs.filter( + (b) => + !muteLists.admin.authors.includes(b.author!) && + !muteLists.admin.replaceableEvents.includes(b.aTag!) + ) + } + + if (filterOptions.moderated === ModeratedFilter.Moderated) { + _blogs = _blogs.filter( + (b) => + !muteLists.user.authors.includes(b.author!) && + !muteLists.user.replaceableEvents.includes(b.aTag!) + ) + } + + if (filterOptions.sort === SortBy.Latest) { + _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 && } + + + +
+ {moderatedAndSortedBlogs.map((b) => ( + + ))} +
+ + {!(page === 1 && !hasMore) && ( + + )} + + ) +} diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts new file mode 100644 index 0000000..aecb15d --- /dev/null +++ b/src/pages/profile/loader.ts @@ -0,0 +1,130 @@ +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 } from 'utils' + +export interface ProfilePageLoaderResult { + profile: UserProfile + isBlocked: boolean + isOwnProfile: boolean + muteLists: { + admin: MuteLists + user: MuteLists + } + nsfwList: string[] +} + +export const profileRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + // Try to decode nprofile parameter + const { nprofile } = params + let profilePubkey: string | undefined + try { + const value = nprofile + ? nip19.decode(nprofile as `nprofile1${string}`) + : undefined + profilePubkey = value?.data.pubkey + } catch (error) { + // Silently ignore and redirect to home or logged in user + log(true, LogType.Error, 'Failed to decode nprofile.', error) + } + + // Get the current state + const userState = store.getState().user + + // Check if current user is logged in + let userPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + userPubkey = userState.user.pubkey as string + } + + // Redirect if profile naddr is missing + // - home if user is not logged + let profileRoute = appRoutes.home + if (!profilePubkey && userPubkey) { + // - own profile + profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: userPubkey + }) + ) + } + if (!profilePubkey) return redirect(profileRoute) + + // Empty result + const result: ProfilePageLoaderResult = { + profile: {}, + isBlocked: false, + isOwnProfile: false, + 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 + } diff --git a/src/pages/write.tsx b/src/pages/write.tsx deleted file mode 100644 index 58813a5..0000000 --- a/src/pages/write.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { CheckboxField, InputField } from '../components/Inputs' -import { ProfileSection } from '../components/ProfileSection' -import { useAppSelector } from '../hooks' -import '../styles/innerPage.css' -import '../styles/styles.css' -import '../styles/write.css' - -export const WritePage = () => { - const userState = useAppSelector((state) => state.user) - - return ( -
-
-
-
-
-
-

- Write a blog post (WIP) -

-
-
- {}} - /> - {}} - /> - {}} - /> - {}} - /> - {}} - type='stylized' - /> -
- -
-
-
- {userState.auth && userState.user?.pubkey && ( - - )} -
-
-
-
- ) -} diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx new file mode 100644 index 0000000..447a605 --- /dev/null +++ b/src/pages/write/action.tsx @@ -0,0 +1,184 @@ +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( + 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 unsignedEvent: UnsignedEvent = { + kind: kinds.LongFormArticle, + created_at: currentTimeStamp, + pubkey: hexPubkey, + content: content, + tags: [ + ['d', uuid], + ['a', aTag], + ['r', rTag], + ['published_at', published_at.toString()], + ['title', formSubmit.title!], + ['image', formSubmit.image!], + ['summary', formSubmit.summary!], + ['nsfw', (formSubmit.nsfw === 'on').toString()], + ...tTags + ] + } + + 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 +): Promise => { + const errors: BlogFormErrors = {} + + if (!formData.title || formData.title.trim() === '') { + errors.title = 'Title field can not be empty' + } + + if ( + !formData.content || + formData.content.trim() === '' || + formData.content.trim() === '

' + ) { + errors.content = 'Content field can not be empty' + } + + if (!formData.summary || formData.summary.trim() === '') { + errors.summary = 'Summary field can not be empty' + } + + if (!formData.tags || formData.tags.trim() === '') { + errors.tags = 'Tags field can not be empty' + } + + if (!formData.image || formData.image.trim() === '') { + errors.image = 'Image url field can not be empty' + } else if ( + !isValidImageUrl(formData.image) || + !(await isReachable(formData.image)) + ) { + errors.image = 'Image must be a valid and reachable' + } + + return errors +} + +function isEditForm( + form: Partial +): form is BlogEventEditForm { + return (form as BlogEventEditForm).dTag !== undefined +} diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx new file mode 100644 index 0000000..870bb2b --- /dev/null +++ b/src/pages/write/index.tsx @@ -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(sanitized) + const editor = useEditor({ + content: content, + extensions: [ + StarterKit, + Link, + Image.configure({ + inline: true, + HTMLAttributes: { + class: 'IBMSMSMBSSPostImg' + } + }) + ], + onUpdate: ({ editor }) => { + setContent(editor.getHTML()) + } + }) + + return ( +
+
+
+
+
+
+

{title}

+
+ {navigation.state === 'loading' && ( + + )} + {navigation.state === 'submitting' && ( + + )} +
+ + {editor && ( +
+ +
+ + +
+ {typeof formErrors?.content !== 'undefined' && ( + + )} + +
+ )} + + + + + {typeof blog?.dTag !== 'undefined' && ( + + )} + {typeof blog?.rTag !== 'undefined' && ( + + )} + {typeof blog?.published_at !== 'undefined' && ( + + )} +
+ +
+ +
+ {userState.auth && userState.user?.pubkey && ( + + )} +
+
+
+
+ ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 18e4d63..5c14fd0 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,21 +1,29 @@ import { createBrowserRouter } from 'react-router-dom' +import { NDKContextType } from 'contexts/NDKContext' import { Layout } from 'layout' -import { SearchPage } from 'pages/search' +import { SearchPage } from '../pages/search' import { AboutPage } from '../pages/about' -import { BlogsPage } from '../pages/blogs' import { GamesPage } from '../pages/games' import { HomePage } from '../pages/home' import { ModPage } from '../pages/mod' import { ModsPage } from '../pages/mods' import { ProfilePage } from '../pages/profile' +import { profileRouteLoader } from 'pages/profile/loader' import { SettingsPage } from '../pages/settings' import { SubmitModPage } from '../pages/submitMod' +import { GamePage } from '../pages/game' +import { NotFoundPage } from '../pages/404' +import { FeedLayout } from '../layout/feed' +import { FeedPage } from '../pages/feed' +import { NotificationsPage } from '../pages/notifications' import { WritePage } from '../pages/write' -import { GamePage } from 'pages/game' -import { NotFoundPage } from 'pages/404' -import { FeedLayout } from 'layout/feed' -import { FeedPage } from 'pages/feed' -import { NotificationsPage } from 'pages/notifications' +import { writeRouteAction } from '../pages/write/action' +import { BlogsPage } from 'pages/blogs' +import { blogsRouteLoader } from 'pages/blogs/loader' +import { BlogPage } from 'pages/blog' +import { blogRouteLoader } from 'pages/blog/loader' +import { blogRouteAction } from 'pages/blog/action' +import { blogReportRouteAction } from 'pages/blog/reportAction' export const appRoutes = { index: '/', @@ -25,7 +33,10 @@ export const appRoutes = { mods: '/mods', mod: '/mod/:naddr', about: '/about', - blog: '/blog', + blogs: '/blog', + blog: '/blog/:naddr', + blogEdit: '/blog/:naddr/edit', + blogReport_actionOnly: '/blog/:naddr/report', submitMod: '/submit-mod', editMod: '/edit-mod/:naddr', write: '/write', @@ -48,94 +59,117 @@ export const getModPageRoute = (eventId: string) => export const getModsEditPageRoute = (eventId: string) => appRoutes.editMod.replace(':naddr', eventId) +export const getBlogPageRoute = (eventId: string) => + appRoutes.blog.replace(':naddr', eventId) + export const getProfilePageRoute = (nprofile: string) => appRoutes.profile.replace(':nprofile', nprofile) -export const router = createBrowserRouter([ - { - element: , - children: [ - { - path: appRoutes.index, - element: - }, - { - path: appRoutes.games, - element: - }, - { - path: appRoutes.game, - element: - }, - { - path: appRoutes.mods, - element: - }, - { - path: appRoutes.mod, - element: - }, - { - path: appRoutes.about, - element: - }, - { - path: appRoutes.blog, - element: - }, - { - path: appRoutes.submitMod, - element: - }, - { - path: appRoutes.editMod, - element: - }, - { - path: appRoutes.write, - element: - }, - { - path: appRoutes.search, - element: - }, - { - path: appRoutes.settingsProfile, - element: - }, - { - path: appRoutes.settingsRelays, - element: - }, - { - path: appRoutes.settingsPreferences, - element: - }, - { - path: appRoutes.settingsAdmin, - element: - }, - { - path: appRoutes.profile, - element: - }, - { - element: , - children: [ - { - path: appRoutes.feed, - element: - }, - { - path: appRoutes.notifications, - element: - } - ] - }, - { - path: '*', - element: - } - ] - } -]) +export const routerWithNdkContext = (context: NDKContextType) => + createBrowserRouter([ + { + element: , + children: [ + { + path: appRoutes.index, + element: + }, + { + path: appRoutes.games, + element: + }, + { + path: appRoutes.game, + element: + }, + { + path: appRoutes.mods, + element: + }, + { + path: appRoutes.mod, + element: + }, + { + path: appRoutes.about, + element: + }, + { + path: appRoutes.blogs, + element: , + loader: blogsRouteLoader(context) + }, + { + path: appRoutes.blog, + element: , + loader: blogRouteLoader(context), + action: blogRouteAction(context) + }, + { + path: appRoutes.blogEdit, + element: , + loader: blogRouteLoader(context), + action: writeRouteAction(context) + }, + { + path: appRoutes.blogReport_actionOnly, + action: blogReportRouteAction(context) + }, + { + path: appRoutes.submitMod, + element: + }, + { + path: appRoutes.editMod, + element: + }, + { + path: appRoutes.write, + element: , + action: writeRouteAction(context) + }, + { + path: appRoutes.search, + element: + }, + { + path: appRoutes.settingsProfile, + element: + }, + { + path: appRoutes.settingsRelays, + element: + }, + { + path: appRoutes.settingsPreferences, + element: + }, + { + path: appRoutes.settingsAdmin, + element: + }, + { + path: appRoutes.profile, + element: , + loader: profileRouteLoader(context) + }, + { + element: , + children: [ + { + path: appRoutes.feed, + element: + }, + { + path: appRoutes.notifications, + element: + } + ] + }, + { + path: '*', + element: + } + ] + } + ]) diff --git a/src/types/blog.ts b/src/types/blog.ts new file mode 100644 index 0000000..7b76549 --- /dev/null +++ b/src/types/blog.ts @@ -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 { + nsfw: string +} + +export interface BlogEventEditForm extends BlogEventSubmitForm { + dTag: string + rTag: string + published_at: string +} + +export interface BlogFormErrors extends Partial {} + +export interface BlogCardDetails extends BlogDetails { + naddr: string +} + +export interface BlogPageLoaderResult { + blog: Partial | undefined + latest: Partial[] + isAddedToNSFW: boolean + isBlocked: boolean +} diff --git a/src/types/index.ts b/src/types/index.ts index c589a79..8fe37df 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,3 +3,4 @@ export * from './modsFilter' export * from './nostr' export * from './user' export * from './zap' +export * from './blog' diff --git a/src/types/nostr.ts b/src/types/nostr.ts index 00e5ade..bdf46a4 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -7,3 +7,9 @@ export interface SignedEvent { id: string sig: string } + +export interface Addressable { + author: string + id: string + aTag: string +} diff --git a/src/utils/blog.ts b/src/utils/blog.ts new file mode 100644 index 0000000..df11b63 --- /dev/null +++ b/src/utils/blog.ts @@ -0,0 +1,38 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { BlogCardDetails, BlogDetails } from 'types' +import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr' +import { kinds, nip19 } from 'nostr-tools' + +export const extractBlogDetails = (event: NDKEvent): Partial => ({ + title: getFirstTagValue(event, 'title'), + content: event.content, + summary: getFirstTagValue(event, 'summary'), + image: getFirstTagValue(event, 'image'), + nsfw: getFirstTagValue(event, 'nsfw') === 'true', + + id: event.id, + author: event.pubkey, + published_at: getFirstTagValueAsInt(event, 'published_at'), + edited_at: event.created_at, + rTag: getFirstTagValue(event, 'r') || 'N/A', + dTag: getFirstTagValue(event, 'd'), + aTag: getFirstTagValue(event, 'a'), + tTags: getTagValues(event, 't') || [] +}) + +export const extractBlogCardDetails = ( + event: NDKEvent +): Partial => { + const blogDetails = extractBlogDetails(event) + + return { + ...blogDetails, + naddr: blogDetails.dTag + ? nip19.naddrEncode({ + identifier: blogDetails.dTag, + kind: kinds.LongFormArticle, + pubkey: event.pubkey + }) + : undefined + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 06e7ca4..3391eb9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './utils' export * from './zap' export * from './localStorage' export * from './consts' +export * from './blog' diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 130d023..772c37b 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -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 diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6c904e0..4658496 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -146,3 +146,13 @@ export const scrollIntoView = (el: HTMLElement | null) => { }, 100) } } + +export const parseFormData = (formData: FormData) => { + const result: Partial = {} + + formData.forEach( + (value, key) => ((result as Record)[key] = value as string) + ) + + return result +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1f3c47c..6363e3e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_REPORTING_NPUB: string readonly VITE_FALLBACK_MOD_IMAGE: string readonly VITE_FALLBACK_GAME_IMAGE: string + readonly VITE_BLOG_NPUBS: string // more env variables... }