Compare commits

..

101 Commits

Author SHA1 Message Date
11de23b7d2 Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-11-14 19:27:53 +00:00
a912e3e43c Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-11-14 19:26:11 +00:00
df0c64e2c9 chore(git): merge pull request #123 from fixes-120-117-84-104 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
Reviewed-on: #123
2024-11-14 16:02:04 +00:00
enes
2ed81c857c refactor(comments): reduce initial load wait and add discovered 2024-11-14 16:50:37 +01:00
enes
18bbc12776 fix(comments): add initial loading indicator 15sec 2024-11-14 14:50:28 +01:00
enes
f7d21807a4 refactor(fetch): add 1min timeout on reactions, 10sec timeout on user relay list 2024-11-14 13:56:42 +01:00
enes
cd3c7ace01 refactor(comments): add dots to comment reactions 2024-11-14 13:55:48 +01:00
enes
aaffc56424 refactor(reactions): use dots loader and block interaction while loading 2024-11-14 11:31:13 +01:00
enes
7b1a70446d feat: spinner and new dots loader 2024-11-14 11:29:01 +01:00
enes
4140438044 fix(comments): link to profile from name and npub
Closes #117
2024-11-13 16:41:15 +01:00
enes
d6bc3b8684 fix(profile): accept npub as valid profile param
Closes #120
2024-11-13 16:24:19 +01:00
296b0ad61d chore(git): merge pull request #122 from fixes-11-13 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
Reviewed-on: #122
2024-11-13 14:13:26 +00:00
enes
0f874c6bbb chore: comment typo 2024-11-13 15:12:45 +01:00
enes
297de3999c fix(comments): hide if missing aTag, force render on blog id change 2024-11-13 15:08:43 +01:00
enes
49435c2b50 fix(home): add missing spinners 2024-11-13 14:30:58 +01:00
enes
718350d2bc fix(blogs): moderation and missing aTag
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-11-13 14:09:13 +01:00
enes
3ee9e313de fix: remove unused var
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2024-11-13 10:58:30 +01:00
enes
91830a539a fix(home): latest blogs published_at sort
Some checks failed
Release to Staging / build_and_release (push) Failing after 23s
2024-11-13 10:55:51 +01:00
enes
834701aa2c fix(home): latest mods published_at sort
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-11-13 10:43:49 +01:00
0c7e61cadd Upload files to "src/assets/img"
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2024-11-13 00:21:43 +00:00
enes
b49ae9537b fix(blog): nsfw filtering, use L tag instead nsfw
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-12 20:15:27 +01:00
enes
352179f1d9 fix(blog): event fetch filter, editing as non-author, add errors
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-11-12 14:25:34 +01:00
enes
77b6aa0d75 refactor(blog): missing blog data will not trigger loading screen 2024-11-12 14:23:58 +01:00
enes
2c31c279a1 refactor(404): more generic error page 2024-11-12 14:22:54 +01:00
enes
7be41272a0 ci(env): add new env vars
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-12 09:58:50 +01:00
cea814676e Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-11-11 23:23:48 +00:00
bd6515ca53 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-11 23:21:04 +00:00
fedd7dd463 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 27s
2024-11-11 23:19:32 +00:00
7ceb109bab Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-11-11 23:13:01 +00:00
b3747e9c22 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-11-11 23:10:21 +00:00
d10b10a4fb Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-11-11 20:33:03 +00:00
2fe0a79009 chore(git): merge pull request #118 from feature/blogs into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
Reviewed-on: #118
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-11-11 12:00:59 +00:00
8af73df889 Merge branch 'staging' into feature/blogs 2024-11-11 12:00:02 +00:00
0d10890ca3 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-11-11 09:25:15 +00:00
4176b06bee Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-11-11 09:23:28 +00:00
enes
178876ab99 feat(blog): editing 2024-11-08 11:14:07 +01:00
enes
1c1430ba5c fix(mod): use existing uuid for edit 2024-11-08 11:02:07 +01:00
enes
2f563e1bfb feat(blog): initial editing 2024-11-07 18:05:19 +01:00
enes
f7f3764686 feat(blog): moderation and more filtering 2024-11-07 17:33:59 +01:00
f734b1447f Update src/styles/post.css 2024-11-06 17:57:16 +01:00
enes
6d6ff8ce43 feat(profile): blogs tab 2024-11-06 17:33:15 +01:00
31fd4ddfb5 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-11-06 15:11:29 +00:00
enes
f30ac01ea6 refactor(blog): re-render body, latest and filtering 2024-11-06 13:17:13 +01:00
enes
31ee0221b7 refactor(blogs): curated filter type 2024-11-06 13:15:30 +01:00
enes
c81b2c0a1d refactor(home): fetch latest blogs in parallel w/ nsfw filter 2024-11-06 13:12:19 +01:00
enes
dae94733fa feat: add react router scroll restoration 2024-11-06 11:13:42 +01:00
enes
2f32f400dd refactor(mod): remove unused import 2024-11-05 16:44:07 +01:00
enes
1d0f27d255 feat(mod): show latest mod author blog posts 2024-11-05 16:43:08 +01:00
enes
a3bec707b0 feat(landing): show latest blog posts 2024-11-05 16:22:08 +01:00
enes
169ab37304 fix: get multiple tag values 2024-11-05 14:42:22 +01:00
enes
73a7b1c1ee feat: admin blog page pagination 2024-11-05 14:11:11 +01:00
enes
847aab29d7 feat: add admin blog page, content parse and markdown 2024-11-05 13:57:39 +01:00
enes
3717c3bfb9 feat: fetching blog data 2024-11-05 13:40:28 +01:00
enes
c2413e1bd8 fix: blog kind 2024-11-05 13:40:28 +01:00
enes
d4148ed01d feat: publishing blog, ndx in router, introduce actions 2024-11-05 13:40:28 +01:00
7f0431f8f8 Update src/styles/cardBlogs.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-11-05 11:53:16 +00:00
3a440d5479 Update src/styles/cardBlogs.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 38s
2024-11-05 11:51:57 +00:00
40dd903e97 Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 39s
2024-10-30 17:01:30 +00:00
68ebaf38bd Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-10-30 16:50:03 +00:00
36aeb53a8c Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 38s
2024-10-30 16:37:33 +00:00
enes
c25d3da64b ci(gitea): trigger release
All checks were successful
Release to Staging / build_and_release (push) Successful in 38s
2024-10-30 17:23:10 +01:00
enes
4eb8c7d653 fix: use subscription for user search
All checks were successful
Release to Staging / build_and_release (push) Successful in 38s
2024-10-30 14:59:44 +01:00
43c8ae4066 Update src/styles/cardMod.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 37s
2024-10-30 13:20:24 +00:00
49c1168bb7 Update src/styles/tags.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 39s
2024-10-30 13:17:46 +00:00
69768388e4 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-10-30 10:00:46 +00:00
80172aee07 Update package.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-10-29 15:45:05 +00:00
enes
0f6cd4a9bd chore: trigger release
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-10-29 16:28:25 +01:00
enes
35fdf2c8b7 chore: trigger release
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-10-29 16:23:54 +01:00
e41ce32ef2 chore(git): merge pull request #105 from 52-game-page-search into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
Reviewed-on: #105
2024-10-29 14:48:29 +00:00
enes
0ee3dba906 fix: user search
Closes #78
2024-10-29 15:44:41 +01:00
enes
efad0f44f5 refactor: use filter storage state, separate profile page filter 2024-10-29 13:38:13 +01:00
enes
6e07f4b8be feat(filter): remember filters, add localstorage hook and utils 2024-10-29 13:21:12 +01:00
enes
7640bdd53b refactor: use SearchInput, search params to q, kind 2024-10-29 09:39:30 +01:00
enes
72252d416b feat: mod search on game page 2024-10-29 09:35:39 +01:00
enes
6e4fa104c0 fix(search): add mods source filter fn
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Closes #77
2024-10-28 14:49:36 +01:00
f2f80a36c6 chore(git): merge pull request #103 from 96-nsfw-list-tag into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #103
2024-10-28 13:02:54 +00:00
enes
4f4e3a7c85 fix(mod-card): add nsfw tag if mod is in nsfwList, while filtering 2024-10-28 14:00:44 +01:00
enes
0b1d43eac4 fix(mod-page): mark mod as nsfw if found in nsfwList 2024-10-28 13:46:46 +01:00
enes
2dc0ab6cf4 fix(profile): hide block on own profile
All checks were successful
Release to Staging / build_and_release (push) Successful in 39s
2024-10-28 13:10:40 +01:00
enes
15af98359d fix(profile): unblock tag filter
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-10-28 13:08:47 +01:00
enes
3906c70bc9 fix(profile): rerender after profile link changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Add useProfile hook, closes #99
2024-10-28 12:43:26 +01:00
enes
9341cd6544 fix(socialNav): active user state icon
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Closes #100
2024-10-28 10:21:12 +01:00
bc782c775a Merge pull request 'comment-fix' (#101) from comment-fix into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #101
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-10-25 08:45:16 +00:00
daniyal
d3c2d5fe7a chore: quick fix 2024-10-24 21:08:40 +05:00
daniyal
d96e5088b8 fix: add timeout in getting user's relay and also pass ndk pool's relays in relayset 2024-10-24 21:07:18 +05:00
daniyal
9aa57c1adf fix: no need to pass relay set to subscribe function, just include p tag with authors pubkey 2024-10-24 20:29:57 +05:00
enes
8ee6f98654 fix: hash router backwards compatibility
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-10-24 13:14:42 +02:00
enes
84cb5b6912 fix: add missing InnerBodyMain div feed layout
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-10-24 11:12:31 +02:00
9b8bf01d33 chore(git): merge pull request #97 from feature/profile-page into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #97
2024-10-24 09:00:46 +00:00
enes
53861a36d3 fix: props and placeholder wip text 2024-10-24 10:48:49 +02:00
enes
99ce338502 feat: browser router and SPA 404.html 2024-10-23 18:00:53 +02:00
enes
76478ad572 fix: quick nav buttons and active state 2024-10-23 17:58:41 +02:00
enes
7393940027 feat: update routes 2024-10-23 17:54:33 +02:00
enes
bb653fa356 fix: add more pages 2024-10-23 17:52:53 +02:00
enes
2e367ecde8 feat: profile page, tabs, mods 2024-10-23 17:51:20 +02:00
enes
a95cd8b6ec refactor: mod report popup 2024-10-23 17:51:20 +02:00
enes
8810673492 refactor: extend checkbox field input 2024-10-23 17:51:20 +02:00
enes
a97a034178 feat: add Tabs component 2024-10-23 17:51:20 +02:00
enes
0102f41403 chore(prettier): css format 2024-10-23 17:51:19 +02:00
63333b38c3 Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-10-22 06:02:17 +00:00
enes
38280d151a fix: rename the workflow
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-10-21 17:24:53 +02:00
84 changed files with 54679 additions and 51242 deletions

View File

@ -11,4 +11,7 @@ VITE_REPORTING_NPUB= <npub1...>
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# A comma separated list of npubs, this list is used to fetch just the posts from the admin
VITE_BLOG_NPUBS= <A comma separated list of npubs>

View File

@ -1,4 +1,4 @@
name: Release to Staging
name: Release to Production
on:
push:
branches:
@ -27,6 +27,8 @@ jobs:
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Create Build

View File

@ -27,6 +27,8 @@ jobs:
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Create Build

View File

@ -35,6 +35,8 @@ jobs:
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Build
run: npm run build

View File

@ -9,6 +9,47 @@
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
<title>DEG Mods - Liberating Game Mods</title>
<!-- Start Hash Router Backwards Compatibility -->
<script type="text/javascript">
;(function (l) {
if (l.hash.startsWith('#/')) {
l.href = l.href.replace('#/', '')
}
})(window.location)
</script>
<!-- End Hash Router Backwards Compatibility -->
<!-- Start Single Page Apps for GitHub Pages -->
<script type="text/javascript">
// Single Page Apps for GitHub Pages
// MIT License
// https://github.com/rafgraph/spa-github-pages
// This script checks to see if a redirect is present in the query string,
// converts it back into the correct url and adds it to the
// browser's history using window.history.replaceState(...),
// which won't cause the browser to attempt to load the new url.
// When the single page app is loaded further down in this file,
// the correct url will be waiting in the browser's history for
// the single page app to route accordingly.
;(function (l) {
if (l.search[1] === '/') {
var decoded = l.search
.slice(1)
.split('&')
.map(function (s) {
return s.replace(/~and~/g, '&')
})
.join('?')
window.history.replaceState(
null,
null,
l.pathname.slice(0, -1) + decoded + l.hash
)
}
})(window.location)
</script>
<!-- End Single Page Apps for GitHub Pages -->
</head>
<body>
<div id="root"></div>

400
package-lock.json generated
View File

@ -1,21 +1,22 @@
{
"name": "degmods.com",
"version": "0.0.0",
"version": "0.0.0-alpha-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "degmods.com",
"version": "0.0.0",
"version": "0.0.0-alpha-1",
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6",
"@tiptap/react": "2.6.6",
"@tiptap/starter-kit": "2.6.6",
"@tiptap/core": "2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-link": "2.9.1",
"@tiptap/react": "2.9.1",
"@tiptap/starter-kit": "2.9.1",
"@types/react-helmet": "^6.1.11",
"axios": "1.7.3",
"bech32": "2.0.0",
@ -26,6 +27,7 @@
"file-saver": "2.0.5",
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"marked": "^14.1.3",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
"papaparse": "5.4.1",
@ -39,6 +41,7 @@
"react-toastify": "10.0.5",
"react-window": "1.8.10",
"swiper": "11.1.11",
"turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},
@ -51,6 +54,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
"@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
@ -1038,6 +1042,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
@ -1152,6 +1162,7 @@
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@ -1181,9 +1192,10 @@
}
},
"node_modules/@remirror/core-constants": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz",
"integrity": "sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ=="
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.17.1",
@ -1479,45 +1491,49 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz",
"integrity": "sha512-VO5qTsjt6rwworkuo0s5AqYMfDA0ZwiTiH6FHKFSu2G/6sS7HKcc/LjPq+5Legzps4QYdBDl3W28wGsGuS1GdQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.9.1.tgz",
"integrity": "sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.6.6"
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.6.6.tgz",
"integrity": "sha512-hAdsNlMfzzxld154hJqPqtWqO5i4/7HoDfuxmyqBxdMJ+e2UMaIGBGwoLRXG0V9UoRwJusjqlpyD7pIorxNlgA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.9.1.tgz",
"integrity": "sha512-Y0jZxc/pdkvcsftmEZFyG+73um8xrx6/DMfgUcNg3JAM63CISedNcr+OEI11L0oFk1KFT7/aQ9996GM6Kubdqg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.6.6.tgz",
"integrity": "sha512-CD6gBhdQtCoqYSmx8oAV8gvKtVOGZSyyvuNYo7by9eZ56DqLYnd7kbUj0RH7o9Ymf/iJTOUJ6XcvrsWwo4lubg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.9.1.tgz",
"integrity": "sha512-e2P1zGpnnt4+TyxTC5pX/lPxPasZcuHCYXY0iwQ3bf8qRQQEjDfj3X7EI+cXqILtnhOiviEOcYmeu5op2WhQDg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.6.6.tgz",
"integrity": "sha512-IkfmlZq67aaegym5sBddBc/xXWCArxn5WJEl1oxKEayjQhybKSaqI7tk0lOx/x7fa5Ml1WlGpCFh+KKXbQTG0g==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.9.1.tgz",
"integrity": "sha512-DWUF6NG08/bZDWw0jCeotSTvpkyqZTi4meJPomG9Wzs/Ol7mEwlNCsCViD999g0+IjyXFatBk4DfUq1YDDu++Q==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
@ -1526,76 +1542,82 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.6.6.tgz",
"integrity": "sha512-WEKxbVSYuvmX2wkHWP8HXk5nzA7stYwtdaubwWH/R17kGI3IGScJuMQ9sEN82uzJU8bfgL9yCbH2bY8Fj/Q4Ow==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.9.1.tgz",
"integrity": "sha512-0hizL/0j9PragJObjAWUVSuGhN1jKjCFnhLQVRxtx4HutcvS/lhoWMvFg6ZF8xqWgIa06n6A7MaknQkqhTdhKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.6.6.tgz",
"integrity": "sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.9.1.tgz",
"integrity": "sha512-WQqcVGe7i/E+yO3wz5XQteU1ETNZ00euUEl4ylVVmH2NM4Dh0KDjEhbhHlCM0iCfLUo7jhjC7dmS+hMdPUb+Tg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.6.6.tgz",
"integrity": "sha512-1YLp/zHMHSkE2xzht8nPR6T4sQJJ3ket798czxWuQEbetFv/l0U/mpiPpYSLObj6oTAoqYZ0kWXZj5eQSpPB8Q==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.9.1.tgz",
"integrity": "sha512-A/50wPWDqEUUUPhrwRKILP5gXMO5UlQ0F6uBRGYB9CEVOREam9yIgvONOnZVJtszHqOayjIVMXbH/JMBeq11/g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.6.6.tgz",
"integrity": "sha512-6qlH5VWzLHHRVeeciRC6C4ZHpMsAGPNG16EF53z0GeMSaaFD/zU3B239QlmqXmLsAl8bpf8Bn93N0t2ABUvScw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.9.1.tgz",
"integrity": "sha512-1a+HCoDPnBttjqExfYLwfABq8MYdiowhy/wp8eCxVb6KGFEENO53KapstISvPzqH7eOi+qRjBB1KtVYb/ZXicg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.6.6.tgz",
"integrity": "sha512-O6CeKriA9uyHsg7Ui4z5ZjEWXQxrIL+1zDekffW0wenGC3G4LUsCzAiFS4LSrR9a3u7tnwqGApW10rdkmCGF4w==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.9.1.tgz",
"integrity": "sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.6.6.tgz",
"integrity": "sha512-lPkESOfAUxgmXRiNqUU23WSyja5FUfSWjsW4hqe+BKNjsUt1OuFMEtYJtNc+MCGhhtPfFvM3Jg6g9jd6g5XsLQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.9.1.tgz",
"integrity": "sha512-MxZ7acNNsoNaKpetxfwi3Z11Bgrh0T2EJlCV77v9N1vWK38+st3H1WJanmLbPNtc2ocvhHJrz+DjDz3CWxQ9rQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
@ -1604,89 +1626,109 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.6.6.tgz",
"integrity": "sha512-O2lQ2t0X0Vsbn3yLWxFFHrXY6C2N9Y6ZF/M7LWzpcDTUZeWuhoNkFE/1yOM0h6ZX1DO2A9hNIrKpi5Ny8yx+QA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.9.1.tgz",
"integrity": "sha512-jsRBmX01vr+5H02GljiHMo0n5H1vzoMLmFarxe0Yq2d2l9G/WV2VWX2XnGliqZAYWd1bI0phs7uLQIN3mxGQTw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.6.6.tgz",
"integrity": "sha512-bsUuyYBrMDEiudx1dOQSr9MzKv13m0xHWrOK+DYxuIDYJb5g+c9un5cK7Js+et/HEYYSPOoH/iTW6h+4I5YeUg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.9.1.tgz",
"integrity": "sha512-fCuaOD/b7nDjm47PZ58oanq7y4ccS2wjPh42Qm0B0yipu/1fmC8eS1SmaXmk28F89BLtuL6uOCtR1spe+lZtlQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.6.6.tgz",
"integrity": "sha512-bgx9vptVFi5yFkIw1OI53J7+xJ71Or3SOe/Q8eSpZv53DlaKpL/TzKw8Z54t1PrI2rJ6H9vrLtkvixJvBZH1Ug==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.9.1.tgz",
"integrity": "sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.6.6.tgz",
"integrity": "sha512-tPTzAmPGqMX5Bd5H8lzRpmsaMvB9DvI5Dy2za/VQuFtxgXmDiFVgHRkRXIuluSkPTuANu84XBOQ0cBijqY8x4w==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.9.1.tgz",
"integrity": "sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.6.6.tgz",
"integrity": "sha512-cFEfv7euDpuLSe8exY8buwxkreKBAZY9Hn3EetKhPcLQo+ut5Y24chZTxFyf9b+Y0wz3UhOhLTZSz7fTobLqBA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.9.1.tgz",
"integrity": "sha512-ydUhABeaBI1CoJp+/BBqPhXINfesp1qMNL/jiDcMsB66fsD4nOyphpAJT7FaRFZFtQVF06+nttBtFZVkITQVqg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.9.1.tgz",
"integrity": "sha512-aGqJnsuS8oagIhsx7wetm8jw4NEDsOV0OSx4FQ4VPlUqWlnzK0N+erFKKJmXTdAxL8PGzoPSlITFH63MV3eV3Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.6.6.tgz",
"integrity": "sha512-t7ZPsXqa8nJZZ/6D0rQyZ/KsvzLaSihC6hBTjUQ77CeDGV9PhDWjIcBW4OrvwraJDBd12ETBeQ2CkULJOgH+lQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.9.1.tgz",
"integrity": "sha512-VkNA6Vz96+/+7uBlsgM7bDXXx4b62T1fDam/3UKifA72aD/fZckeWrbT7KrtdUbzuIniJSbA0lpTs5FY29+86Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.6.6.tgz",
"integrity": "sha512-NJSR5Yf/dI3do0+Mr6e6nkbxRQcqbL7NOPxo5Xw8VaKs2Oe8PX+c7hyqN3GZgn6uEbZdbVi1xjAniUokouwpFg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.9.1.tgz",
"integrity": "sha512-yG+e3e8cCCN9dZjX4ttEe3e2xhh58ryi3REJV4MdiEkOT9QF75Bl5pUbMIS4tQ8HkOr04QBFMHKM12kbSxg1BA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.1.0"
},
@ -1695,78 +1737,97 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.6.6.tgz",
"integrity": "sha512-k+oEzZu2cgVKqPqOP1HzASOKLpTEV9m7mRVPAbuaaX8mSyvIgD6f+JUx9PvgYv//D918wk98LMoRBFX53tDJ4w==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.9.1.tgz",
"integrity": "sha512-6O4NtYNR5N2Txi4AC0/4xMRJq9xd4+7ShxCZCDVL0WDVX37IhaqMO7LGQtA6MVlYyNaX4W1swfdJaqrJJ5HIUw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.6.6.tgz",
"integrity": "sha512-AJwyfLXIi7iUGnK5twJbwdVVpQyh7fU6OK75h1AwDztzsOcoPcxtffDlZvUOd4ZtwuyhkzYqVkeI0f+abTWZTw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.9.1.tgz",
"integrity": "sha512-6J9jtv1XP8dW7/JNSH/K4yiOABc92tBJtgCsgP8Ep4+fjfjdj4HbjS1oSPWpgItucF2Fp/VF8qg55HXhjxHjTw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.6.6.tgz",
"integrity": "sha512-fD/onCr16UQWx+/xEmuFC2MccZZ7J5u4YaENh8LMnAnBXf78iwU7CAcmuc9rfAEO3qiLoYGXgLKiHlh2ZfD4wA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.9.1.tgz",
"integrity": "sha512-JOmT0xd4gd3lIhLwrsjw8lV+ZFROKZdIxLi0Ia05XSu4RLrrvWj0zdKMSB+V87xOWfSB3Epo95zAvnPox5Q16A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.6.6.tgz",
"integrity": "sha512-Ze8KhGk+wzSJSJRl5fbhTI6AvPu2LmcHYeO3pMEH8u4gV5WTXfmKJVStEIAzkoqvwEQVWzXvy8nDgsFQHiojPg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.9.1.tgz",
"integrity": "sha512-V5aEXdML+YojlPhastcu7w4biDPwmzy/fWq0T2qjfu5Te/THcqDmGYVBKESBm5x6nBy5OLkanw2O+KHu2quDdg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.6.6.tgz",
"integrity": "sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.9.1.tgz",
"integrity": "sha512-3wo9uCrkLVLQFgbw2eFU37QAa1jq1/7oExa+FF/DVxdtHRS9E2rnUZ8s2hat/IWzvPUHXMwo3Zg2XfhoamQpCA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.9.1.tgz",
"integrity": "sha512-LAxc0SeeiPiAVBwksczeA7BJSZb6WtVpYhy5Esvy9K0mK5kttB4KxtnXWeQzMIJZQbza65yftGKfQlexf/Y7yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.6.6.tgz",
"integrity": "sha512-56FGLPn3fwwUlIbLs+BO21bYfyqP9fKyZQbQyY0zWwA/AG2kOwoXaRn7FOVbjP6CylyWpFJnpRRmgn694QKHEg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.9.1.tgz",
"integrity": "sha512-mvV86fr7kEuDYEApQ2uMPCKL2uagUE0BsXiyyz3KOkY1zifyVm1fzdkscb24Qy1GmLzWAIIihA+3UHNRgYdOlQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.2.1",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.5.2",
"prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@ -1774,14 +1835,14 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.22.2",
"prosemirror-model": "^1.22.3",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-trailing-node": "^2.0.9",
"prosemirror-transform": "^1.9.0",
"prosemirror-view": "^1.33.9"
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.0",
"prosemirror-view": "^1.34.3"
},
"funding": {
"type": "github",
@ -1789,13 +1850,15 @@
}
},
"node_modules/@tiptap/react": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.6.6.tgz",
"integrity": "sha512-AUmdb/J1O/vCO2b8LL68ctcZr9a3931BwX4fUUZ1kCrCA5lTj2xz0rjeAtpxEdzLnR+Z7q96vB7vf7bPYOUAew==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.9.1.tgz",
"integrity": "sha512-LQJ34ZPfXtJF36SZdcn4Fiwsl2WxZ9YRJI87OLnsjJ45O+gV/PfBzz/4ap+LF8LOS0AbbGhTTjBOelPoNm+aYA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.6.6",
"@tiptap/extension-floating-menu": "^2.6.6",
"@tiptap/extension-bubble-menu": "^2.9.1",
"@tiptap/extension-floating-menu": "^2.9.1",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1.2.2"
},
"funding": {
@ -1803,8 +1866,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
@ -1815,30 +1878,32 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@tiptap/starter-kit": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.6.6.tgz",
"integrity": "sha512-zb9xIg3WjG9AsJoyWrfqx5SL9WH7/HTdkB79jFpWtOF/Kaigo7fHFmhs2FsXtJMJlcdMTO2xeRuCYHt5ozXlhg==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.9.1.tgz",
"integrity": "sha512-nsw6UF/7wDpPfHRhtGOwkj1ipIEiWZS1VGw+c14K61vM1CNj0uQ4jogbHwHZqN1dlL5Hh+FCqUHDPxG6ECbijg==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bold": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-horizontal-rule": "^2.6.6",
"@tiptap/extension-italic": "^2.6.6",
"@tiptap/extension-list-item": "^2.6.6",
"@tiptap/extension-ordered-list": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-strike": "^2.6.6",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/pm": "^2.6.6"
"@tiptap/core": "^2.9.1",
"@tiptap/extension-blockquote": "^2.9.1",
"@tiptap/extension-bold": "^2.9.1",
"@tiptap/extension-bullet-list": "^2.9.1",
"@tiptap/extension-code": "^2.9.1",
"@tiptap/extension-code-block": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
"@tiptap/extension-dropcursor": "^2.9.1",
"@tiptap/extension-gapcursor": "^2.9.1",
"@tiptap/extension-hard-break": "^2.9.1",
"@tiptap/extension-heading": "^2.9.1",
"@tiptap/extension-history": "^2.9.1",
"@tiptap/extension-horizontal-rule": "^2.9.1",
"@tiptap/extension-italic": "^2.9.1",
"@tiptap/extension-list-item": "^2.9.1",
"@tiptap/extension-ordered-list": "^2.9.1",
"@tiptap/extension-paragraph": "^2.9.1",
"@tiptap/extension-strike": "^2.9.1",
"@tiptap/extension-text": "^2.9.1",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/pm": "^2.9.1"
},
"funding": {
"type": "github",
@ -2032,6 +2097,13 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
},
"node_modules/@types/turndown": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@ -3193,8 +3265,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.3.2",
@ -3847,6 +3918,18 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/marked": {
"version": "14.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz",
"integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@ -4504,11 +4587,12 @@
}
},
"node_modules/prosemirror-trailing-node": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-2.0.9.tgz",
"integrity": "sha512-YvyIn3/UaLFlFKrlJB6cObvUhmwFNZVhy1Q8OpW/avoTbD/Y7H5EcjK4AZFKhmuS6/N6WkGgt7gWtBWDnmFvHg==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "^2.0.2",
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
@ -4521,6 +4605,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -4537,9 +4622,10 @@
}
},
"node_modules/prosemirror-view": {
"version": "1.34.1",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.1.tgz",
"integrity": "sha512-KS2xmqrAM09h3SLu1S2pNO/ZoIP38qkTJ6KFd7+BeSfmX/ek0n5yOfGuiTZjFNTC8GOsEIUa1tHxt+2FMu3yWQ==",
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.35.0.tgz",
"integrity": "sha512-Umtbh22fmUlpZpRTiOVXA0PpdRZeYEeXQsLp51VfnMhjkJrqJ0n8APinIZrRAD5Jr3UxH8FnOaUqRylSuMsqHA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -5028,6 +5114,7 @@
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
@ -5149,6 +5236,15 @@
"resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz",
"integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw=="
},
"node_modules/turndown": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz",
"integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "degmods.com",
"private": true,
"version": "0.0.0",
"version": "0.0.0-alpha-1",
"type": "module",
"scripts": {
"dev": "vite",
@ -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",

51
public/404.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Single Page Apps for GitHub Pages</title>
<script type="text/javascript">
// Single Page Apps for GitHub Pages
// MIT License
// https://github.com/rafgraph/spa-github-pages
// This script takes the current url and converts the path and query
// string into just a query string, and then redirects the browser
// to the new url with only a query string and hash fragment,
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
// Note: this 404.html file must be at least 512 bytes for it to work
// with Internet Explorer (it is currently > 512 bytes)
// If you're creating a Project Pages site and NOT using a custom domain,
// then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
// This way the code will only replace the route part of the path, and not
// the real directory in which the app resides, for example:
// https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
// https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
// Otherwise, leave pathSegmentsToKeep as 0.
var pathSegmentsToKeep = 0
var l = window.location
l.replace(
l.protocol +
'//' +
l.hostname +
(l.port ? ':' + l.port : '') +
l.pathname
.split('/')
.slice(0, 1 + pathSegmentsToKeep)
.join('/') +
'/?/' +
l.pathname
.slice(1)
.split('/')
.slice(pathSegmentsToKeep)
.join('/')
.replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash
)
</script>
</head>
<body></body>
</html>

View File

@ -1,10 +1,12 @@
import { Route, Routes } from 'react-router-dom'
import { Layout } from './layout'
import { routes } from './routes'
import { RouterProvider } from 'react-router-dom'
import { useEffect } from 'react'
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')
@ -22,19 +24,7 @@ function App() {
}
}, [])
return (
<Routes>
<Route element={<Layout />}>
{routes.map((route, index) => (
<Route
key={route.path + index}
path={route.path}
element={route.element}
/>
))}
</Route>
</Routes>
)
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
}
export default App

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

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

View File

@ -1,2 +1,2 @@
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
1 Game Name 16 by 9 image Boxart image
2 Marvel's Spider-Man 2 https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -1,37 +1,33 @@
import { Link } from 'react-router-dom'
import { BlogCardDetails } from 'types'
import { getBlogPageRoute } from 'routes'
import '../styles/cardBlogs.css'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
type BlogCardProps = {
backgroundLink: string
}
type BlogCardProps = Partial<BlogCardDetails>
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
if (!naddr) return null
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
return (
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
<div
className='cardBlogMain'
style={{
background: `url("${backgroundLink}") center / cover no-repeat`
background: `url("${
image ? image : placeholder
}") center / cover no-repeat`
}}
>
<div
className='cardBlogMainInside'
>
<h3
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
WebkitLineClamp: 2,
fontSize: '20px',
lineHeight: 1.5,
color: 'rgba(255, 255, 255, 0.75)',
textShadow: '0 0 8px rgba(0, 0, 0, 0.25)'
}}
>
This is a blog title, the best blog title in the world!
</h3>
<div className='cardBlogMainInside'>
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
{nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
<p>NSFW</p>
</div>
)}
</div>
</div>{' '}
</a>
</div>
</Link>
)
}

View File

@ -1,4 +1,5 @@
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useEffect } from 'react'
@ -89,13 +90,27 @@ interface CheckboxFieldProps {
name: string
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type?: 'default' | 'stylized'
}
export const CheckboxField = React.memo(
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label className='form-label labelMain'>{label}</label>
({
label,
name,
isChecked,
handleChange,
type = 'default'
}: CheckboxFieldProps) => (
<div
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
}`}
>
<label htmlFor={name} className='form-label labelMain'>
{label}
</label>
<input
id={name}
type='checkbox'
className='CheckboxMain'
name={name}
@ -113,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())
@ -144,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')
@ -167,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',
@ -197,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) => ({
@ -230,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()
@ -284,7 +323,55 @@ const MenuBarButton = ({
onClick={onClick}
disabled={disabled}
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
type='button'
>
{label}
</button>
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
description?: string
error?: string
}
/**
* Uncontrolled input component with design classes, label, description and error support
*
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
* @param label
* @param description
* @param error
*
* @see {@link React.ComponentProps<'input'>}
*/
export const InputFieldUncontrolled = ({
label,
description,
error,
...rest
}: InputFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
<input className='inputMain' {...rest} />
{error && <InputError message={error} />}
</div>
)
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
}
export const CheckboxFieldUncontrolled = ({
label,
...rest
}: CheckboxFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
<input type='checkbox' className='CheckboxMain' {...rest} />
</div>
)

View File

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

View File

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

View File

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

View File

@ -7,14 +7,14 @@ import {
} from 'hooks'
import { useState } from 'react'
import { toast } from 'react-toastify'
import { ModDetails } from 'types'
import { Addressable } from 'types'
import { abbreviateNumber } from 'utils'
type ZapProps = {
modDetails: ModDetails
addressable: Addressable
}
export const Zap = ({ modDetails }: ZapProps) => {
export const Zap = ({ addressable }: ZapProps) => {
const [isOpen, setIsOpen] = useState(false)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [hasZapped, setHasZapped] = useState(false)
@ -26,9 +26,9 @@ export const Zap = ({ modDetails }: ZapProps) => {
useDidMount(() => {
getTotalZapAmount(
modDetails.author,
modDetails.id,
modDetails.aTag,
addressable.author,
addressable.id,
addressable.aTag,
userState.user?.pubkey as string
)
.then((res) => {
@ -70,9 +70,9 @@ export const Zap = ({ modDetails }: ZapProps) => {
</div>
{isOpen && (
<ZapSplit
pubkey={modDetails.author}
eventId={modDetails.id}
aTag={modDetails.aTag}
pubkey={addressable.author}
eventId={addressable.id}
aTag={addressable.aTag}
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
handleClose={() => setIsOpen(false)}

View File

@ -12,7 +12,7 @@ import { useComments } from 'hooks/useComments'
export const ModCard = React.memo((props: ModDetails) => {
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [commentCount, setCommentCount] = useState(0)
const { commentEvents } = useComments(props)
const { commentEvents } = useComments(props.author, props.aTag)
const { likesCount, disLikesCount } = useReactions({
pubkey: props.author,
eTag: props.id,

View File

@ -192,7 +192,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
return
}
const uuid = uuidv4()
const uuid = formState.dTag || uuidv4()
const currentTimeStamp = now()
const aTag =
@ -204,7 +204,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
pubkey: hexPubkey,
content: formState.body,
tags: [
['d', formState.dTag || uuid],
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
@ -395,6 +395,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
name='nsfw'
isChecked={formState.nsfw}
handleChange={handleCheckboxChange}
type='stylized'
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>

View File

@ -1,16 +1,20 @@
import { useAppSelector } from 'hooks'
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import { Dispatch, SetStateAction } from 'react'
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
type Props = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
author?: string | undefined
filterKey?: string | undefined
}
export const ModFilter = React.memo(
({ filterOptions, setFilterOptions }: Props) => {
({ author, filterKey = 'filter' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<div className='IBMSecMain'>
@ -61,7 +65,12 @@ export const ModFilter = React.memo(
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
const isOwnProfile =
author &&
userState.auth &&
userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (

View File

@ -14,7 +14,7 @@ import { appRoutes, getProfilePageRoute } from '../routes'
import '../styles/author.css'
import '../styles/innerPage.css'
import '../styles/socialPosts.css'
import { UserProfile, UserRelaysType } from '../types'
import { UserRelaysType } from '../types'
import {
copyTextToClipboard,
hexToNpub,
@ -27,37 +27,18 @@ import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { useProfile } from 'hooks/useProfile'
type Props = {
pubkey: string
}
export const ProfileSection = ({ pubkey }: Props) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
findMetadata(pubkey).then((res) => {
setProfile(res)
})
})
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
return (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile
pubkey={pubkey}
displayName={displayName}
about={about}
image={profile?.image}
nip05={profile?.nip05}
lud16={profile?.lud16}
/>
<Profile pubkey={pubkey} />
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
@ -109,21 +90,18 @@ export const ProfileSection = ({ pubkey }: Props) => {
type ProfileProps = {
pubkey: string
displayName: string
about: string
image?: string
nip05?: string
lud16?: string
}
export const Profile = ({
pubkey,
displayName,
about,
image,
nip05,
lud16
}: ProfileProps) => {
export const Profile = ({ pubkey }: ProfileProps) => {
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
const image = profile?.image || FALLBACK_PROFILE_IMAGE
const nip05 = profile?.nip05
const lud16 = profile?.lud16
const npub = hexToNpub(pubkey)
const handleCopy = async () => {
@ -138,14 +116,20 @@ export const Profile = ({
})
}
// Try to encode
let profileRoute = appRoutes.home
const hexPubkey = npubToHex(pubkey)
if (hexPubkey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: hexPubkey
})
)
let nprofile: string | undefined
try {
const hexPubkey = npubToHex(pubkey)
nprofile = hexPubkey
? nip19.nprofileEncode({
pubkey: hexPubkey
})
: undefined
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
} catch (error) {
// Silently ignore and redirect to home
log(true, LogType.Error, 'Failed to encode profile.', error)
}
return (
@ -162,9 +146,7 @@ export const Profile = ({
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `url('${
image || FALLBACK_PROFILE_IMAGE
}') center / cover no-repeat`
background: `url('${image}') center / cover no-repeat`
}}
></div>
</div>
@ -172,7 +154,8 @@ export const Profile = ({
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
{nip05 && (
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
{typeof nip05 === 'string' && (
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
)}
</div>
@ -205,8 +188,12 @@ export const Profile = ({
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
<ProfileQRButtonWithPopUp pubkey={pubkey} />
{lud16 && <ZapButtonWithPopUp pubkey={pubkey} />}
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
{typeof lud16 !== 'undefined' && (
<ZapButtonWithPopUp pubkey={pubkey} />
)}
</div>
</div>
</div>
@ -251,20 +238,16 @@ const posts: Post[] = [
]
type QRButtonWithPopUpProps = {
pubkey: string
nprofile: string
}
export const ProfileQRButtonWithPopUp = ({
pubkey
nprofile
}: QRButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
useBodyScrollDisable(isOpen)
const nprofile = nip19.nprofileEncode({
pubkey
})
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
const a = document.createElement('a')

View File

@ -0,0 +1,39 @@
import { forwardRef } from 'react'
interface SearchInputProps {
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
handleSearch: () => void
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ handleKeyDown, handleSearch }, ref) => (
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={ref}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
)
)

View File

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

26
src/components/Tabs.tsx Normal file
View File

@ -0,0 +1,26 @@
interface TabsProps {
tabs: string[]
tab: number
setTab: React.Dispatch<React.SetStateAction<number>>
}
export const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSNav'>
{tabs.map((t, i) => {
return (
<button
key={t}
className={`btn btnMain IBMSMSMFSSNavBtn${
tab === i ? ' IBMSMSMFSSNavBtnActive' : ''
}`}
type='button'
onClick={() => setTab(i)}
>
{t}
</button>
)
})}
</div>
)
}

View File

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

View File

@ -20,6 +20,12 @@ export const LANDING_PAGE_DATA = {
'Cyberpunk 2077',
'ELDEN RING',
'The Coffin of Andy and Leyley'
],
featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2573jhg9trsu6vgav9gnn4dffkzk2ww3yrjejnc2s'
]
}
// we use this object to check if a user has reacted positively or negatively to a post
@ -112,7 +118,8 @@ export const REACTIONS = {
export const MAX_MODS_PER_PAGE = 10
export const MAX_GAMES_PER_PAGE = 10
// todo: add game and mod fallback image here
export const FALLBACK_PROFILE_IMAGE =
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
export const PROFILE_BLOG_FILTER_LIMIT = 20

View File

@ -21,7 +21,8 @@ import {
log,
LogType,
npubToHex,
orderEventsChronologically
orderEventsChronologically,
timeout
} from 'utils'
type FetchModsOptions = {
@ -29,9 +30,10 @@ type FetchModsOptions = {
until?: number
since?: number
limit?: number
author?: string
}
interface NDKContextType {
export interface NDKContextType {
ndk: NDK
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
@ -146,7 +148,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
source,
until,
since,
limit
limit,
author
}: FetchModsOptions): Promise<ModDetails[]> => {
// Define the filter criteria for fetching mods
const filter: NDKFilter = {
@ -154,7 +157,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
'#t': [T_TAG_VALUE],
until, // Optional filter to fetch events until this timestamp
since // Optional filter to fetch events from this timestamp
since, // Optional filter to fetch events from this timestamp
authors: author ? [author] : undefined // Optional filter to fetch events from only this author
}
// If the source matches the current window location, add a filter condition
@ -238,8 +242,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
hexKey: string,
userRelaysType: UserRelaysType
): Promise<NDKEvent[]> => {
// Find the user's relays.
const relayUrls = await getRelayListForUser(hexKey, ndk)
// Find the user's relays (10s timeout).
const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined

View File

@ -7,3 +7,4 @@ export * from './useNSFWList'
export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'

View File

@ -7,20 +7,32 @@ import {
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { useEffect, useState } from 'react'
import { CommentEvent, ModDetails, UserRelaysType } from 'types'
import { log, LogType } from 'utils'
import { CommentEvent, UserRelaysType } from 'types'
import { log, LogType, timeout } from 'utils'
import { useNDKContext } from './useNDKContext'
export const useComments = (mod: ModDetails) => {
export const useComments = (
author: string | undefined,
aTag: string | undefined
) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => {
if (!(author && aTag)) {
// Author and aTag are required
return
}
let subscription: NDKSubscription // Define the subscription variable here for cleanup
const setupSubscription = async () => {
// Find the mod author's relays.
const authorReadRelays = await getRelayListForUser(mod.author, ndk)
const authorReadRelays = await Promise.race([
getRelayListForUser(author, ndk),
timeout(10 * 1000) // add a 10 sec timeout
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[UserRelaysType.Read]
return [] // Return an empty array if ndkRelayList is undefined
@ -29,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[]
@ -37,16 +49,24 @@ export const useComments = (mod: ModDetails) => {
const filter: NDKFilter = {
kinds: [NDKKind.Text],
'#a': [mod.aTag]
'#a': [aTag]
}
const relayUrls = new Set<string>()
ndk.pool.urls().forEach((relayUrl) => {
relayUrls.add(relayUrl)
})
authorReadRelays.forEach((relayUrl) => relayUrls.add(relayUrl))
subscription = ndk.subscribe(
filter,
{
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
},
NDKRelaySet.fromRelayUrls(authorReadRelays, ndk, true)
NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
)
subscription.on('event', (ndkEvent) => {
@ -80,7 +100,7 @@ export const useComments = (mod: ModDetails) => {
subscription.stop()
}
}
}, [mod.aTag, mod.author, ndk])
}, [aTag, author, ndk])
return {
commentEvents,

View File

@ -8,6 +8,7 @@ import {
NSFWFilter,
SortBy
} from 'types'
import { npubToHex } from 'utils'
export const useFilteredMods = (
mods: ModDetails[],
@ -17,10 +18,20 @@ export const useFilteredMods = (
muteLists: {
admin: MuteLists
user: MuteLists
}
},
author?: string | undefined
) => {
return useMemo(() => {
const nsfwFilter = (mods: ModDetails[]) => {
// Add nsfw tag to mods included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
mods = mods.map((mod) => {
return !mod.nsfw && nsfwList.includes(mod.aTag)
? { ...mod, nsfw: true }
: mod
})
}
// Determine the filtering logic based on the NSFW filter option
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
@ -38,11 +49,15 @@ export const useFilteredMods = (
let filtered = nsfwFilter(mods)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.npub &&
npubToHex(userState.user.npub as string) === author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
@ -70,6 +85,7 @@ export const useFilteredMods = (
filterOptions.sort,
filterOptions.moderated,
filterOptions.nsfw,
author,
mods,
muteLists,
nsfwList

View File

@ -0,0 +1,50 @@
import React from 'react'
import {
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem
} from 'utils'
const useLocalStorageSubscribe = (callback: () => void) => {
window.addEventListener('storage', callback)
return () => window.removeEventListener('storage', callback)
}
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const getSnapshot = () => getLocalStorageItem(key, initialValue)
const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot)
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
(v: React.SetStateAction<T>) => {
try {
const nextState =
typeof v === 'function'
? (v as (prevState: T) => T)(JSON.parse(data))
: v
if (nextState === undefined || nextState === null) {
removeLocalStorageItem(key)
} else {
setLocalStorageItem(key, JSON.stringify(nextState))
}
} catch (e) {
console.warn(e)
}
},
[key, data]
)
React.useEffect(() => {
// Set local storage only when it's empty
const data = window.localStorage.getItem(key)
if (data === null) {
setLocalStorageItem(key, JSON.stringify(initialValue))
}
}, [key, initialValue])
return [JSON.parse(data) as T, setState]
}

View File

@ -1,4 +1,4 @@
import { NDKContext } from 'contexts/NDKContext'
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
import { useContext } from 'react'
export const useNDKContext = () => {
@ -9,5 +9,5 @@ export const useNDKContext = () => {
'NDKContext should not be used in out component tree hierarchy'
)
return { ...ndkContext }
return { ...ndkContext } as NDKContextType
}

18
src/hooks/useProfile.tsx Normal file
View File

@ -0,0 +1,18 @@
import { useNDKContext } from 'hooks'
import { useState, useEffect } from 'react'
import { UserProfile } from 'types'
export const useProfile = (pubkey?: string) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
useEffect(() => {
if (pubkey) {
findMetadata(pubkey).then((res) => {
setProfile(res)
})
}
}, [findMetadata, pubkey])
return profile
}

View File

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

10
src/layout/feed.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom'
export const FeedLayout = () => {
return (
<div className='InnerBodyMain'>
<h1>WIP</h1>
<Outlet />
</div>
)
}

View File

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

View File

@ -1,4 +1,4 @@
import { Outlet } from 'react-router-dom'
import { Outlet, ScrollRestoration } from 'react-router-dom'
import { Footer } from './footer'
import { Header } from './header'
import { SocialNav } from './socialNav'
@ -12,6 +12,7 @@ export const Layout = () => {
<Outlet />
<Footer />
<SocialNav />
<ScrollRestoration />
</>
)
}

View File

@ -1,24 +1,32 @@
import { useAppSelector } from 'hooks'
import { nip19 } from 'nostr-tools'
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { NavLink, NavLinkProps } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import 'styles/socialNav.css'
import { npubToHex } from 'utils'
export const SocialNav = () => {
const location = useLocation()
const currentPath = location.pathname
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
const userState = useAppSelector((state) => state.user)
let profileRoute = ''
if (userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
const toggleNav = () => {
setIsCollapsed(!isCollapsed)
}
const isOnHomePage =
currentPath === appRoutes.index || currentPath === appRoutes.home
const isOnSearchPage = currentPath === appRoutes.search
const isOnProfilePage = new RegExp(
`^${appRoutes.profile.replace(':nprofile', '[^/]+')}$`
).test(currentPath)
return (
<div
className='socialNav'
@ -32,30 +40,27 @@ export const SocialNav = () => {
<div className='socialNavInside'>
<NavButton
to={appRoutes.home}
isActive={isOnHomePage}
viewBox="0 -32 576 576"
viewBox='0 -32 576 576'
svgPath='M511.8 287.6L512.5 447.7C512.5 450.5 512.3 453.1 512 455.8V472C512 494.1 494.1 512 472 512H456C454.9 512 453.8 511.1 452.7 511.9C451.3 511.1 449.9 512 448.5 512H392C369.9 512 352 494.1 352 472V384C352 366.3 337.7 352 320 352H256C238.3 352 224 366.3 224 384V472C224 494.1 206.1 512 184 512H128.1C126.6 512 125.1 511.9 123.6 511.8C122.4 511.9 121.2 512 120 512H104C81.91 512 64 494.1 64 472V360C64 359.1 64.03 358.1 64.09 357.2V287.6H32.05C14.02 287.6 0 273.5 0 255.5C0 246.5 3.004 238.5 10.01 231.5L266.4 8.016C273.4 1.002 281.4 0 288.4 0C295.4 0 303.4 2.004 309.5 7.014L416 100.7V64C416 46.33 430.3 32 448 32H480C497.7 32 512 46.33 512 64V185L564.8 231.5C572.8 238.5 576.9 246.5 575.8 255.5C575.8 273.5 560.8 287.6 543.8 287.6L511.8 287.6z'
/>
<NavButton
to={appRoutes.home}
isActive={false}
to={appRoutes.feed}
svgPath='M88 48C101.3 48 112 58.75 112 72V120C112 133.3 101.3 144 88 144H40C26.75 144 16 133.3 16 120V72C16 58.75 26.75 48 40 48H88zM480 64C497.7 64 512 78.33 512 96C512 113.7 497.7 128 480 128H192C174.3 128 160 113.7 160 96C160 78.33 174.3 64 192 64H480zM480 224C497.7 224 512 238.3 512 256C512 273.7 497.7 288 480 288H192C174.3 288 160 273.7 160 256C160 238.3 174.3 224 192 224H480zM480 384C497.7 384 512 398.3 512 416C512 433.7 497.7 448 480 448H192C174.3 448 160 433.7 160 416C160 398.3 174.3 384 192 384H480zM16 232C16 218.7 26.75 208 40 208H88C101.3 208 112 218.7 112 232V280C112 293.3 101.3 304 88 304H40C26.75 304 16 293.3 16 280V232zM88 368C101.3 368 112 378.7 112 392V440C112 453.3 101.3 464 88 464H40C26.75 464 16 453.3 16 440V392C16 378.7 26.75 368 40 368H88z'
/>
<NavButton
to={appRoutes.home}
isActive={false}
to={appRoutes.notifications}
svgPath='M256 32V51.2C329 66.03 384 130.6 384 208V226.8C384 273.9 401.3 319.2 432.5 354.4L439.9 362.7C448.3 372.2 450.4 385.6 445.2 397.1C440 408.6 428.6 416 416 416H32C19.4 416 7.971 408.6 2.809 397.1C-2.353 385.6-.2883 372.2 8.084 362.7L15.5 354.4C46.74 319.2 64 273.9 64 226.8V208C64 130.6 118.1 66.03 192 51.2V32C192 14.33 206.3 0 224 0C241.7 0 256 14.33 256 32H256zM224 512C207 512 190.7 505.3 178.7 493.3C166.7 481.3 160 464.1 160 448H288C288 464.1 281.3 481.3 269.3 493.3C257.3 505.3 240.1 512 224 512z'
/>
<NavButton
to={appRoutes.search}
isActive={isOnSearchPage}
svgPath='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'
/>
<NavButton
to={getProfilePageRoute('xyz')}
isActive={isOnProfilePage}
svgPath='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'
/>
{!!userState.auth && (
<NavButton
to={profileRoute}
svgPath='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'
/>
)}
</div>
)}
<div className='socialNavCollapse' onClick={toggleNav}>
@ -78,19 +83,23 @@ export const SocialNav = () => {
)
}
interface NavButtonProps {
to: string
isActive: boolean
interface NavButtonProps extends NavLinkProps {
svgPath: string
viewBox?: string
}
const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButtonProps) => (
<Link
to={to}
className={`btn btnMain socialNavInsideBtn ${
isActive ? 'socialNavInsideBtnActive' : ''
}`}
const NavButton = ({
svgPath,
viewBox = '0 0 512 512',
...rest
}: NavButtonProps) => (
<NavLink
{...rest}
className={({ isActive }) =>
`btn btnMain socialNavInsideBtn ${
isActive ? 'socialNavInsideBtnActive' : ''
}`
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -101,7 +110,5 @@ const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButton
>
<path d={svgPath}></path>
</svg>
</Link>
);
</NavLink>
)

View File

@ -1,7 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { HashRouter } from 'react-router-dom'
import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import App from './App.tsx'
@ -12,12 +11,10 @@ import { NDKContextProvider } from 'contexts/NDKContext.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<NDKContextProvider>
<App />
</NDKContextProvider>
<ToastContainer />
</HashRouter>
<NDKContextProvider>
<App />
</NDKContextProvider>
<ToastContainer />
</Provider>
</React.StrictMode>
)

40
src/pages/404.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Link, useRouteError } from 'react-router-dom'
import { appRoutes } from 'routes'
interface NotFoundPageProps {
title: string
message: string
}
export const NotFoundPage = ({
title = 'Page not found',
message = "The page you're attempting to visit doesn't exist"
}: Partial<NotFoundPageProps>) => {
const error = useRouteError() as Partial<NotFoundPageProps>
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{error?.title || title}</h2>
</div>
<div>
<p>{error?.message || message}</p>
</div>
<div className='IBMSMAction'>
<Link
to={appRoutes.home}
className='btn btnMain IBMSMActionBtn'
type='button'
>
Go home
</Link>
</div>
</div>
</div>
</div>
</div>
)
}

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

@ -0,0 +1,264 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { ActionFunctionArgs } from 'react-router-dom'
import { toast } from 'react-toastify'
import { store } from 'store'
import { UserRelaysType } from 'types'
import { log, LogType, now, signAndPublish } from 'utils'
export const blogRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return null
}
// Decode author from naddr
let aTag: string | undefined
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
aTag = `${kind}:${pubkey}:${identifier}`
} catch (error) {
log(true, LogType.Error, 'Failed to decode naddr')
return null
}
if (!aTag) {
log(true, LogType.Error, 'Missing #a Tag')
return null
}
const userState = store.getState().user
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
}
if (!hexPubkey) {
toast.error('Failed to get the pubkey')
return null
}
const isAdmin =
userState.user?.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const handleBlock = async () => {
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [kinds.Mutelist],
authors: [hexPubkey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
if (alreadyExists) {
toast.warn(`Blog reference is already in user's mute list`)
return null
}
tags.push(['a', aTag])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: kinds.Mutelist,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: hexPubkey,
kind: kinds.Mutelist,
content: '',
created_at: now(),
tags: [['a', aTag]]
}
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's mute list")
}
return null
}
const handleUnblock = async () => {
const filter: NDKFilter = {
kinds: [kinds.Mutelist],
authors: [hexPubkey]
}
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`)
return null
}
const tags = muteListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: kinds.Mutelist,
content: muteListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's mute list")
}
return null
}
const handleAddNSFW = async () => {
const filter: NDKFilter = {
kinds: [kinds.Curationsets],
authors: [hexPubkey],
'#d': ['nsfw']
}
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (nsfwListEvent) {
const tags = nsfwListEvent.tags
const alreadyExists =
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
if (alreadyExists) {
toast.warn(`Blog reference is already in user's nsfw list`)
return null
}
tags.push(['a', aTag])
unsignedEvent = {
pubkey: nsfwListEvent.pubkey,
kind: kinds.Curationsets,
content: nsfwListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: hexPubkey,
kind: kinds.Curationsets,
content: '',
created_at: now(),
tags: [
['a', aTag],
['d', 'nsfw']
]
}
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's nsfw list")
}
return null
}
const handleRemoveNSFW = async () => {
const filter: NDKFilter = {
kinds: [kinds.Curationsets],
authors: [hexPubkey],
'#d': ['nsfw']
}
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
if (!nsfwListEvent) {
toast.error(`Couldn't get nsfw list event from relays`)
return null
}
const tags = nsfwListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: nsfwListEvent.pubkey,
kind: kinds.Curationsets,
content: nsfwListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's nsfw list")
}
return null
}
const requestData = (await request.json()) as {
intent: 'nsfw' | 'block'
value: boolean
}
switch (requestData.intent) {
case 'block':
await (requestData.value ? handleBlock() : handleUnblock())
break
case 'nsfw':
if (!isAdmin) {
log(true, LogType.Error, 'Unable to update NSFW list. No permission')
return null
}
await (requestData.value ? handleAddNSFW() : handleRemoveNSFW())
break
default:
log(true, LogType.Error, 'Missing intent for blog action')
break
}
return null
}

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

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

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

@ -0,0 +1,233 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19 } from 'nostr-tools'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes } from 'routes'
import { store } from 'store'
import {
BlogPageLoaderResult,
FilterOptions,
ModeratedFilter,
NSFWFilter
} from 'types'
import {
DEFAULT_FILTER_OPTIONS,
getLocalStorageItem,
log,
LogType
} from 'utils'
import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog'
export const blogRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params, request }: LoaderFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs)
}
// Decode author and identifier from naddr
let pubkey: string | undefined
let identifier: string | undefined
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
pubkey = decoded.data.pubkey
identifier = decoded.data.identifier
} catch (error) {
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error)
throw new Error('Failed to fetch the blog. The address might be wrong')
}
const userState = store.getState().user
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
// Check if editing and the user is the original author
// Redirect if NOT
const url = new URL(request.url)
const isEditMode = url.pathname.endsWith('/edit')
if (isEditMode && loggedInUserPubkey !== pubkey) {
return redirect(appRoutes.blogs)
}
try {
// Set the filter for the main blog content
const filter = {
kinds: [kinds.LongFormArticle],
authors: [pubkey],
'#d': [identifier]
}
// Get the blog filter options for latest blogs
const filterOptions = JSON.parse(
getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS)
) as FilterOptions
// Fetch more in case the current blog is included in the latest and filters remove some
const latestFilter: NDKFilter = {
authors: [pubkey],
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Add source filter
if (filterOptions.source === window.location.host) {
latestFilter['#r'] = [filterOptions.source]
}
// Filter by NSFW tag
// NSFWFilter.Only_NSFW -> fetch with content-warning label
// NSFWFilter.Show_NSFW -> filter not needed
// NSFWFilter.Hide_NSFW -> up the limit and filter after fetch
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
// Parallel fetch blog event, latest events, mute, and nsfw lists
const settled = await Promise.allSettled([
ndkContext.fetchEvent(filter),
ndkContext.fetchEvents(latestFilter),
ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
ndkContext.getNSFWList()
])
const result: BlogPageLoaderResult = {
blog: undefined,
latest: [],
isAddedToNSFW: false,
isBlocked: false
}
// Check the blog event result
const fetchEventResult = settled[0]
if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) {
// Extract the blog details from the event
result.blog = extractBlogDetails(fetchEventResult.value)
} else if (fetchEventResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the blog event.',
fetchEventResult.reason
)
}
// Throw an error if we are missing the main blog result
// Handle it with the react-router's errorComponent
if (!result.blog) {
throw new Error('We are unable to find the blog on the relays')
}
// Check the lateast blog events
const fetchEventsResult = settled[1]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
result.latest = fetchEventsResult.value
.map(extractBlogCardDetails)
.filter((b) => b.id !== result.blog?.id) // Filter out current blog if present
} else if (fetchEventsResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the latest blog events.',
fetchEventsResult.reason
)
}
const muteLists = settled[2]
if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteLists && muteLists.value) {
if (result.blog && result.blog.aTag) {
if (
muteLists.value.admin.replaceableEvents.includes(
result.blog.aTag
) ||
muteLists.value.user.replaceableEvents.includes(result.blog.aTag)
) {
result.isBlocked = true
}
}
// Moderate the latest
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === pubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.admin.authors.includes(b.author!) &&
!muteLists.value.admin.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.user.authors.includes(b.author!) &&
!muteLists.value.user.replaceableEvents.includes(b.aTag!)
)
}
}
} else if (muteLists.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason)
}
const nsfwList = settled[3]
if (nsfwList.status === 'fulfilled' && nsfwList.value) {
// Check if the blog is marked as NSFW
// Mark it as NSFW only if it's missing the tag
if (result.blog) {
const isMissingNsfwTag =
!result.blog.nsfw &&
result.blog.aTag &&
nsfwList.value.includes(result.blog.aTag)
if (isMissingNsfwTag) {
result.blog.nsfw = true
}
if (result.blog.aTag && nsfwList.value.includes(result.blog.aTag)) {
result.isAddedToNSFW = true
}
}
// Check the latest blogs too
result.latest = result.latest.map((b) => {
// Add nsfw tag if it's missing
const isMissingNsfwTag =
!b.nsfw && b.aTag && nsfwList.value.includes(b.aTag)
if (isMissingNsfwTag) {
b.nsfw = true
}
return b
})
} else if (nsfwList.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason)
}
// Filter latest, sort and take only three
result.latest = result.latest
.filter(
// Filter out the NSFW if selected
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
.slice(0, 3)
return result
} catch (error) {
let message = 'An error occurred in fetching blog details from relays'
log(true, LogType.Error, message, error)
if (error instanceof Error) {
message = error.message
throw new Error(message)
}
}
}

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

@ -0,0 +1,92 @@
import { useFetcher } from 'react-router-dom'
import { CheckboxFieldUncontrolled } from 'components/Inputs'
import { useEffect } from 'react'
type ReportPopupProps = {
handleClose: () => void
}
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
{ label: 'Spam', key: 'spam' },
{ label: 'Scam', key: 'scam' },
{ label: 'Malware', key: 'malware' },
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
{ label: 'Other', key: 'otherReason' }
]
export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
const fetcher = useFetcher()
// Close automatically if action succeeds
useEffect(() => {
if (fetcher.data) {
const { isSent } = fetcher.data
console.log(fetcher.data)
if (isSent) {
handleClose()
}
}
}, [fetcher, handleClose])
return (
<>
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<fetcher.Form
className='pUMCB_ZapsInside'
method='post'
action='report'
>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting this?
</label>
{BLOG_REPORT_REASONS.map((r) => (
<CheckboxFieldUncontrolled
key={r.key}
label={r.label}
name={r.key}
defaultChecked={false}
/>
))}
</div>
<button
className='btn btnMain pUMCB_Report'
type='submit'
style={{ width: '100%' }}
>
Submit Report
</button>
</fetcher.Form>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

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

View File

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

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

@ -0,0 +1,208 @@
import { useMemo, useRef, useState } from 'react'
import { useLoaderData, useNavigation, useSearchParams } from 'react-router-dom'
import { useLocalStorage } from 'hooks'
import { BlogCardDetails, NSFWFilter, SortBy } from 'types'
import { SearchInput } from '../../components/SearchInput'
import { BlogCard } from '../../components/BlogCard'
import '../../styles/filters.css'
import '../../styles/pagination.css'
import '../../styles/search.css'
import '../../styles/styles.css'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner'
export const BlogsPage = () => {
const navigation = useNavigation()
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
const [filterOptions, setFilterOptions] = useLocalStorage(
'filter-blog-curated',
{
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
}
)
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
// Filter
const filteredBlogs = useMemo(() => {
const filterNsfwFn = (blog: Partial<BlogCardDetails>) => {
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
return !blog.nsfw
case NSFWFilter.Only_NSFW:
return blog.nsfw
default:
return blog
}
}
let filtered = blogs?.filter(filterNsfwFn) || []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
if (searchTerm !== '') {
const filterSearchTermFn = (blog: Partial<BlogCardDetails>) =>
(blog.title || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.summary || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.content || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.tTags || []).findIndex((tag) =>
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
filtered = filtered.filter(filterSearchTermFn)
}
if (filterOptions.sort === SortBy.Latest) {
filtered.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
} else if (filterOptions.sort === SortBy.Oldest) {
filtered.sort((a, b) =>
a.published_at && b.published_at ? a.published_at - b.published_at : 0
)
}
return filtered
}, [blogs, searchTerm, filterOptions.sort, filterOptions.nsfw])
// Pagination logic
const [currentPage, setCurrentPage] = useState(1)
const scrollTargetRef = useRef<HTMLDivElement>(null)
const MAX_BLOGS_PER_PAGE = 16
const totalBlogs = filteredBlogs.length
const totalPages = Math.ceil(totalBlogs / MAX_BLOGS_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_BLOGS_PER_PAGE
const endIndex = startIndex + MAX_BLOGS_PER_PAGE
const currentMods = filteredBlogs.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
scrollIntoView(scrollTargetRef.current)
setCurrentPage(page)
}
}
return (
<div className='InnerBodyMain'>
{navigation.state !== 'idle' && <LoadingSpinner desc={'Loading'} />}
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs</h2>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
</div>
</div>
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods &&
currentMods.map((b) => <BlogCard key={b.id} {...b} />)}
</div>
</div>
{totalPages > 1 && (
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
)}
</div>
</div>
</div>
)
}

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

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

3
src/pages/feed.tsx Normal file
View File

@ -0,0 +1,3 @@
export const FeedPage = () => {
return <h2>Feed</h2>
}

View File

@ -6,24 +6,25 @@ import {
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { SearchInput } from 'components/SearchInput'
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails } from 'types'
import {
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy
} from 'types'
import { extractModData, isModDataComplete, scrollIntoView } from 'utils'
DEFAULT_FILTER_OPTIONS,
extractModData,
isModDataComplete,
scrollIntoView
} from 'utils'
export const GamePage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
@ -33,20 +34,70 @@ export const GamePage = () => {
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const [mods, setMods] = useState<ModDetails[]>([])
const [currentPage, setCurrentPage] = useState(1)
const userState = useAppSelector((state) => state.user)
const filteredMods = useFilteredMods(
mods,
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
}
// Handle "Enter" key press inside the input
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
const filteredMods = useMemo(() => {
const filterSourceFn = (mod: ModDetails) => {
if (filterOptions.source === window.location.host) {
return mod.rTag === filterOptions.source
}
return true
}
// If search term is missing, only filter by sources
if (searchTerm === '') return mods.filter(filterSourceFn)
const lowerCaseSearchTerm = searchTerm.toLowerCase()
const filterFn = (mod: ModDetails) =>
mod.title.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.game.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.summary.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.body.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.tags.findIndex((tag) =>
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
const filteredModList = useFilteredMods(
filteredMods,
userState,
filterOptions,
nsfwList,
@ -54,11 +105,11 @@ export const GamePage = () => {
)
// Pagination logic
const totalGames = filteredMods.length
const totalGames = filteredModList.length
const totalPages = Math.ceil(totalGames / MAX_MODS_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_MODS_PER_PAGE
const endIndex = startIndex + MAX_MODS_PER_PAGE
const currentMods = filteredMods.slice(startIndex, endIndex)
const currentMods = filteredModList.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
@ -116,14 +167,24 @@ export const GamePage = () => {
<span className='IBMSMTitleMainHeadingSpan'>
{gameName}
</span>
{searchTerm !== '' && (
<>
&nbsp;&mdash;&nbsp;
<span className='IBMSMTitleMainHeadingSpan'>
{searchTerm}
</span>
</>
)}
</h2>
</div>
<SearchInput
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
ref={searchTermRef}
/>
</div>
</div>
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<ModFilter />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods.map((mod) => (

View File

@ -9,6 +9,7 @@ import '../styles/styles.css'
import { createSearchParams, useNavigate } from 'react-router-dom'
import { appRoutes } from 'routes'
import { scrollIntoView } from 'utils'
import { SearchInput } from 'components/SearchInput'
export const GamesPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
@ -74,8 +75,8 @@ export const GamesPage = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
if (value !== '') {
const searchParams = createSearchParams({
searchTerm: value,
searching: 'Games'
q: value,
kind: 'Games'
})
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
}
@ -100,34 +101,11 @@ export const GamesPage = () => {
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Games</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>

View File

@ -1,22 +1,30 @@
import { nip19 } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate, useNavigation } from 'react-router-dom'
import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
import { BlogCard } from '../components/BlogCard'
import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA } from '../constants'
import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants'
import {
useDidMount,
useGames,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { ModDetails } from '../types'
import { extractModData, handleModImageError, log, LogType } from '../utils'
import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types'
import {
extractBlogCardDetails,
extractModData,
handleModImageError,
log,
LogType,
npubToHex
} from '../utils'
import '../styles/cardLists.css'
import '../styles/SimpleSlider.css'
@ -27,7 +35,8 @@ import { NDKFilter } from '@nostr-dev-kit/ndk'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { Spinner } from 'components/Spinner'
export const HomePage = () => {
const navigate = useNavigate()
@ -114,27 +123,7 @@ export const HomePage = () => {
</div>
</div>
<DisplayLatestMods />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
</div>
<div className='IBMSMList'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
>
View All
</a>
</div>
</div>
<DisplayLatestBlogs />
</div>
</div>
</div>
@ -267,6 +256,8 @@ const DisplayLatestMods = () => {
useDidMount(() => {
fetchMods({ source: window.location.host })
.then((mods) => {
// Sort by the latest (published_at descending)
mods.sort((a, b) => b.published_at - a.published_at)
setLatestMods(mods)
})
.finally(() => {
@ -320,10 +311,136 @@ const DisplayLatestMods = () => {
)
}
const Spinner = () => {
const DisplayLatestBlogs = () => {
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog-curated', {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
})
const navigation = useNavigation()
useDidMount(() => {
const fetchBlogs = async () => {
try {
// Show maximum of 4 blog posts
// 2 should be featured and the most recent 2 from blog npubs
// Populate the filter from known naddr (constants.ts)
const filter: NDKFilter = {
kinds: [kinds.LongFormArticle],
authors: [],
'#d': []
}
for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) {
try {
const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i]
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { pubkey, identifier } = decoded.data
if (!filter.authors?.includes(pubkey)) {
filter.authors?.push(pubkey)
}
if (!filter.authors?.includes(identifier)) {
filter['#d']?.push(identifier)
}
} catch (error) {
// Silently ignore
}
}
// Prepare filter for the latest
const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',')
const blogHexkeys = blogNpubs
.map(npubToHex)
.filter((hexkey) => hexkey !== null)
// We fetch more posts in case of duplicates (from featured)
const latestFilter: NDKFilter = {
authors: blogHexkeys,
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Filter by NSFW tag
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
const results = await Promise.allSettled([
fetchEvents(filter),
fetchEvents(latestFilter)
])
const events: Partial<BlogCardDetails>[] = []
// Get featured blogs posts result
results.forEach((r) => {
// Add events from both promises to the array
if (r.status === 'fulfilled' && r.value) {
events.push(
...r.value
.map(extractBlogCardDetails) // Extract the blog card details
.sort(
// Sort each result by published_at in descending order
// We can't sort everything at once we'd lose prefered
(a, b) =>
a.published_at && b.published_at
? b.published_at - a.published_at
: 0
)
)
}
})
// Remove duplicates
const unique = Array.from(
events
.filter((b) => b.id)
.reduce((map, obj) => {
map.set(obj.id!, obj)
return map
}, new Map<string, Partial<BlogCardDetails>>())
.values()
).filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
const latest = unique.slice(0, 4)
setBlogs(latest)
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return null
}
}
fetchBlogs()
})
return (
<div className='spinner'>
<div className='spinnerCircle'></div>
<div className='IBMSecMain IBMSMListWrapper'>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Fetching blog...'} />
)}
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts</h2>
</div>
<div className='IBMSMList'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
<div className='IBMSMAction'>
<Link
className='btn btnMain IBMSMActionBtn'
role='button'
to={appRoutes.blogs}
>
View All
</Link>
</div>
</div>
)
}

View File

@ -2,9 +2,8 @@ import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import Link from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { formatDate } from 'date-fns'
import FsLightbox from 'fslightbox-react'
import { nip19, UnsignedEvent } from 'nostr-tools'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { Link as ReactRouterLink, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
@ -15,7 +14,8 @@ import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext
useNDKContext,
useNSFWList
} from '../../hooks'
import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
import '../../styles/comments.css'
@ -28,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,
@ -42,18 +46,27 @@ import {
sendDMUsingRandomKey,
signAndPublish
} from '../../utils'
import { Comments } from './internal/comment'
import { Reactions } from './internal/reactions'
import { Zap } from './internal/zap'
import placeholder from '../../assets/img/DEGMods Placeholder Img.png'
import { Comments } from '../../components/comment'
import { CheckboxField } from 'components/Inputs'
import { PublishDetails } from 'components/Internal/PublishDetails'
import { Interactions } from 'components/Internal/Interactions'
import { extractBlogCardDetails } from 'utils/blog'
export const ModPage = () => {
const { naddr } = useParams()
const { fetchEvent } = useNDKContext()
const [modData, setModData] = useState<ModDetails>()
const [mod, setMod] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(true)
const [commentCount, setCommentCount] = useState(0)
// Make sure to mark non-nsfw mods as NSFW if found in nsfwList
const nsfwList = useNSFWList()
const isMissingNsfwTag =
!mod?.nsfw && mod?.aTag && nsfwList && nsfwList.includes(mod.aTag)
const modData = isMissingNsfwTag
? ({ ...mod, nsfw: true } as ModDetails)
: mod
useDidMount(async () => {
if (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
@ -69,7 +82,7 @@ export const ModPage = () => {
.then((event) => {
if (event) {
const extracted = extractModData(event)
setModData(extracted)
setMod(extracted)
}
})
.catch((err) => {
@ -133,7 +146,7 @@ export const ModPage = () => {
nsfw={modData.nsfw}
/>
<Interactions
modDetails={modData}
addressable={modData}
commentCount={commentCount}
/>
<PublishDetails
@ -181,21 +194,10 @@ export const ModPage = () => {
)}
</div>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>
Creator's Blog Posts (WIP)
</h4>
<div className='IBMSMList IBMSMListAlt'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
</div>
</div>
<DisplayModAuthorBlogs />
<div className='IBMSMSplitMainBigSideSec'>
<Comments
modDetails={modData}
addressable={modData}
setCommentCount={setCommentCount}
/>
</div>
@ -666,18 +668,25 @@ type ReportPopupProps = {
handleClose: () => void
}
const MOD_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
{ label: 'Spam', key: 'spam' },
{ label: 'Scam', key: 'scam' },
{ label: 'Not a game mod', key: 'notAGameMod' },
{ label: 'Stolen game mod', key: 'stolenGameMod' },
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
{ label: 'Other reason', key: 'otherReason' }
]
const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState({
actuallyCP: false,
spam: false,
scam: false,
notAGameMod: false,
stolenGameMod: false,
wasntTaggedNSFW: false,
otherReason: false
})
const [selectedOptions, setSelectedOptions] = useState(
MOD_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
acc[cur.key] = false
return acc
}, {})
)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -823,86 +832,15 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
>
Why are you reporting this?
</label>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Actually CP
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.actuallyCP}
onChange={() => handleCheckboxChange('actuallyCP')}
{MOD_REPORT_REASONS.map((r) => (
<CheckboxField
key={r.key}
label={r.label}
name={r.key}
isChecked={selectedOptions[r.key]}
handleChange={() => handleCheckboxChange(r.key)}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Spam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.spam}
onChange={() => handleCheckboxChange('spam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Scam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.scam}
onChange={() => handleCheckboxChange('scam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Not a game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.notAGameMod}
onChange={() => handleCheckboxChange('notAGameMod')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Stolen game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.stolenGameMod}
onChange={() => handleCheckboxChange('stolenGameMod')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Wasn't tagged NSFW
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.wasntTaggedNSFW}
onChange={() => handleCheckboxChange('wasntTaggedNSFW')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Other reason
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.otherReason}
onChange={() => handleCheckboxChange('otherReason')}
/>
</div>
))}
</div>
<button
className='btn btnMain pUMCB_Report'
@ -1026,126 +964,6 @@ const Body = ({
)
}
type InteractionsProps = {
modDetails: ModDetails
commentCount: number
}
const Interactions = ({ modDetails, commentCount }: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap modDetails={modDetails} />
<Reactions modDetails={modDetails} />
</div>
</div>
)
}
type PublishDetailsProps = {
published_at: number
edited_at: number
site: string
}
const PublishDetails = ({
published_at,
edited_at,
site
}: PublishDetailsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/MM/yyyy hh:mm:ss aa'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}
const Download = ({
url,
hash,
@ -1403,3 +1221,49 @@ const Download = ({
</div>
)
}
const DisplayModAuthorBlogs = () => {
const { naddr } = useParams()
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext()
useDidMount(() => {
const fetchBlogs = async () => {
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { pubkey } = decoded.data
const latestBlogPosts = await fetchEvents({
authors: [pubkey],
kinds: [kinds.LongFormArticle],
limit: 3
})
setBlogs(latestBlogPosts.map(extractBlogCardDetails))
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return null
}
}
fetchBlogs()
})
if (!blogs?.length) return null
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
<div className='IBMSMList IBMSMListAlt'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
</div>
</div>
)
}

View File

@ -8,6 +8,7 @@ import { MOD_FILTER_LIMIT } from '../constants'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
@ -17,26 +18,20 @@ import '../styles/filters.css'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import {
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy
} from '../types'
import { scrollIntoView } from 'utils'
import { FilterOptions, ModDetails } from '../types'
import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils'
import { SearchInput } from 'components/SearchInput'
export const ModsPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const { fetchMods } = useNDKContext()
const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
@ -112,10 +107,7 @@ export const ModsPage = () => {
ref={scrollTargetRef}
>
<PageTitleRow />
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<ModFilter />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
@ -146,8 +138,8 @@ const PageTitleRow = React.memo(() => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
if (value !== '') {
const searchParams = createSearchParams({
searchTerm: value,
searching: 'Mods'
q: value,
kind: 'Mods'
})
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
}
@ -166,35 +158,11 @@ const PageTitleRow = React.memo(() => {
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Mods</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
</div>
</div>
)

View File

@ -0,0 +1,3 @@
export const NotificationsPage = () => {
return <h2>Notifications</h2>
}

View File

@ -1,3 +0,0 @@
export const ProfilePage = () => {
return <h1>WIP</h1>
}

845
src/pages/profile/index.tsx Normal file
View File

@ -0,0 +1,845 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { Pagination } from 'components/Pagination'
import { ProfileSection } from 'components/ProfileSection'
import { Tabs } from 'components/Tabs'
import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import { kinds, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLoaderData, useNavigation } from 'react-router-dom'
import { toast } from 'react-toastify'
import { appRoutes } from 'routes'
import {
BlogCardDetails,
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy,
UserRelaysType
} from 'types'
import {
copyTextToClipboard,
DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails,
now,
npubToHex,
scrollIntoView,
sendDMUsingRandomKey,
signAndPublish
} from 'utils'
import { CheckboxField } from 'components/Inputs'
import { ProfilePageLoaderResult } from './loader'
import { BlogCard } from 'components/BlogCard'
export const ProfilePage = () => {
const {
profilePubkey,
profile,
isBlocked: _isBlocked,
isOwnProfile
} = useLoaderData() as ProfilePageLoaderResult
const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const [showReportPopUp, setShowReportPopUp] = useState(false)
const [isBlocked, setIsBlocked] = useState(_isBlocked)
const handleBlock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for updating mute list')
setIsLoading(false)
return
}
setLoadingSpinnerDesc(`Finding user's mute list`)
// 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: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === profilePubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
setIsBlocked(true)
return toast.warn(`User is already in the mute list`)
}
tags.push(['p', profilePubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', profilePubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(true)
}
setIsLoading(false)
}
const handleUnblock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
const userHexKey = userState.user?.pubkey as string
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
setIsLoading(true)
setLoadingSpinnerDesc(`Finding user's mute list`)
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`)
return
}
const tags = muteListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'p' || item[1] !== profilePubkey)
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(false)
}
setIsLoading(false)
}
// Tabs
const [tab, setTab] = useState(0)
const [page, setPage] = useState(1)
// Mods
const [mods, setMods] = useState<ModDetails[]>([])
const filterKey = 'filter-profile'
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS
})
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const handleNext = useCallback(() => {
setIsLoading(true)
const until =
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
fetchMods({
source: filterOptions.source,
until,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
const handlePrev = useCallback(() => {
setIsLoading(true)
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
fetchMods({
source: filterOptions.source,
since,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
useEffect(() => {
setIsLoading(true)
switch (tab) {
case 0:
setLoadingSpinnerDesc('Fetching mods..')
fetchMods({ source: filterOptions.source, author: profilePubkey })
.then((res) => {
setMods(res)
})
.finally(() => {
setIsLoading(false)
})
break
default:
setIsLoading(false)
break
}
}, [filterOptions.source, tab, fetchMods, profilePubkey])
const filteredModList = useFilteredMods(
mods,
userState,
filterOptions,
nsfwList,
muteLists,
profilePubkey
)
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Profile'>
<div className='IBMSMSMBSSModFor'>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<p className='IBMSMSMBSSModForPara'>{displayName}'s Page</p>
<div
className='dropdown dropdownMain'
style={{ flexGrow: 'unset' }}
>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className='dropdown-menu dropdown-menu-end dropdownMainMenu'>
{isOwnProfile && (
<Link
className='dropdown-item dropdownMainMenuItem'
to={appRoutes.settingsProfile}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</Link>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then(
(isCopied) => {
if (isCopied)
toast.success('Url copied to clipboard!')
}
)
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a
className='dropdown-item dropdownMainMenuItem'
href='#'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
{!isOwnProfile && (
<>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportUser'
onClick={() => setShowReportPopUp(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a
className='dropdown-item dropdownMainMenuItem'
onClick={isBlocked ? handleUnblock : handleBlock}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 72.74-130.8c7 8 98.88 125.4 98.88 125.4l58.63-66.88c4.125 6.75 7.867 13.52 11.24 19.9C364.9 290.6 353.4 357.4 304.1 391.9z'></path>
</svg>
{isBlocked ? 'Unblock' : 'Block User'}
</a>
</>
)}
</div>
</div>
</div>
<Tabs
tabs={['Mods', 'Blogs', 'Posts']}
tab={tab}
setTab={setTab}
/>
{/* Tabs Content */}
{tab === 0 && (
<>
<ModFilter filterKey={filterKey} author={profilePubkey} />
<div className='IBMSMList IBMSMListAlt'>
{filteredModList.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
</div>
<Pagination
page={page}
disabledNext={mods.length < MOD_FILTER_LIMIT}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)}
{tab === 1 && <ProfileTabBlogs />}
{tab === 2 && <>WIP</>}
</div>
</div>
</div>
<ProfileSection pubkey={profilePubkey} />
</div>
</div>
{showReportPopUp && (
<ReportUserPopup
reportedPubkey={profilePubkey}
handleClose={() => setShowReportPopUp(false)}
/>
)}
</div>
</div>
)
}
type ReportUserPopupProps = {
reportedPubkey: string
handleClose: () => void
}
const USER_REPORT_REASONS = [
{ label: `User posts actual CP`, key: 'user_actuallyCP' },
{ label: `User is a spammer`, key: 'user_spam' },
{ label: `User is a scammer`, key: 'user_scam' },
{ label: `User posts malware`, key: 'user_malware' },
{ label: `User posts non-mods`, key: 'user_notAGameMod' },
{ label: `User doesn't tag NSFW`, key: 'user_wasntTaggedNSFW' },
{ label: `Other (user)`, key: 'user_otherReason' }
]
const ReportUserPopup = ({
reportedPubkey,
handleClose
}: ReportUserPopupProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState(
USER_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
acc[cur.key] = false
return acc
}, {})
)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const handleCheckboxChange = (option: keyof typeof selectedOptions) => {
setSelectedOptions((prevState) => ({
...prevState,
[option]: !prevState[option]
}))
}
const handleSubmit = async () => {
const selectedOptionsCount = Object.values(selectedOptions).filter(
(isSelected) => isSelected
).length
if (selectedOptionsCount === 0) {
toast.error('At least one option should be checked!')
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
let userHexKey: string
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for reporting user!')
setIsLoading(false)
return
}
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const reportingPubkey = npubToHex(reportingNpub)
if (reportingPubkey === userHexKey) {
setLoadingSpinnerDesc(`Finding user's mute list`)
// 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: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === reportedPubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
return toast.warn(
`Reporter user's pubkey is already in the mute list`
)
}
tags.push(['p', reportedPubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', reportedPubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) handleClose()
} else {
const href = window.location.href
let message = `I'd like to report ${href} due to following reasons:\n`
Object.entries(selectedOptions).forEach(([key, value]) => {
if (value) {
message += `* ${key}\n`
}
})
setLoadingSpinnerDesc('Sending report')
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
ndk,
publish
)
if (isSent) handleClose()
}
setIsLoading(false)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting the user?
</label>
{USER_REPORT_REASONS.map((r) => (
<CheckboxField
key={r.key}
label={r.label}
name={r.key}
isChecked={selectedOptions[r.key]}
handleChange={() => handleCheckboxChange(r.key)}
/>
))}
<button
className='btn btnMain pUMCB_Report'
type='button'
style={{ width: '100%' }}
onClick={handleSubmit}
>
Submit Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
const ProfileTabBlogs = () => {
const { profile, muteLists, nsfwList } =
useLoaderData() as ProfilePageLoaderResult
const navigation = useNavigation()
const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS)
const [isLoading, setIsLoading] = useState(true)
const blogfilter: NDKFilter = useMemo(() => {
const filter: NDKFilter = {
authors: [profile?.pubkey as string],
kinds: [kinds.LongFormArticle]
}
const host = window.location.host
if (filterOptions.source === host) {
filter['#r'] = [host]
}
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
filter['#L'] = ['content-warning']
}
return filter
}, [filterOptions.nsfw, filterOptions.source, profile?.pubkey])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>([])
useEffect(() => {
if (profile) {
// Initial blog fetch, go beyond limit to check for next
const filter: NDKFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT + 1
}
fetchEvents(filter)
.then((events) => {
setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT)
})
.finally(() => {
setIsLoading(false)
})
}
}, [blogfilter, fetchEvents, profile])
const handleNext = useCallback(() => {
if (isLoading) return
const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined
if (last?.published_at) {
const until = last?.published_at - 1
const nextFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT + 1,
until
}
setIsLoading(true)
fetchEvents(nextFilter)
.then((events) => {
const nextBlogs = events.map(extractBlogCardDetails)
setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT)
setPage((prev) => prev + 1)
setBlogs(
nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr)
)
})
.finally(() => setIsLoading(false))
}
}, [blogfilter, blogs, fetchEvents, isLoading])
const handlePrev = useCallback(() => {
if (isLoading) return
const first = blogs.length > 0 ? blogs[0] : undefined
if (first?.published_at) {
const since = first.published_at + 1
const prevFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT,
since
}
setIsLoading(true)
fetchEvents(prevFilter)
.then((events) => {
setHasMore(true)
setPage((prev) => prev - 1)
setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
})
.finally(() => setIsLoading(false))
}
}, [blogfilter, blogs, fetchEvents, isLoading])
const userState = useAppSelector((state) => state.user)
const moderatedAndSortedBlogs = useMemo(() => {
let _blogs = blogs || []
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === profile?.pubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Add nsfw tag to blogs included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
_blogs = _blogs.map((b) => {
return !b.nsfw && b.aTag && nsfwList.includes(b.aTag)
? { ...b, nsfw: true }
: b
})
}
// Filter nsfw (Hide_NSFW option)
_blogs = _blogs.filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
_blogs = _blogs.filter(
(b) =>
!muteLists.admin.authors.includes(b.author!) &&
!muteLists.admin.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
_blogs = _blogs.filter(
(b) =>
!muteLists.user.authors.includes(b.author!) &&
!muteLists.user.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.sort === SortBy.Latest) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
} else if (filterOptions.sort === SortBy.Oldest) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? a.published_at - b.published_at : 0
)
}
return _blogs
}, [
blogs,
filterOptions.moderated,
filterOptions.nsfw,
filterOptions.sort,
muteLists.admin.authors,
muteLists.admin.replaceableEvents,
muteLists.user.authors,
muteLists.user.replaceableEvents,
nsfwList,
profile?.pubkey,
userState.user?.npub,
userState.user?.pubkey
])
return (
<>
{(isLoading || navigation.state !== 'idle') && (
<LoadingSpinner desc={'Loading...'} />
)}
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
<div className='IBMSMList IBMSMListAlt'>
{moderatedAndSortedBlogs.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
{!(page === 1 && !hasMore) && (
<Pagination
page={page}
disabledNext={!hasMore}
handlePrev={handlePrev}
handleNext={handleNext}
/>
)}
</>
)
}

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

@ -0,0 +1,147 @@
import { NDKContextType } from 'contexts/NDKContext'
import { nip19 } from 'nostr-tools'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import { store } from 'store'
import { MuteLists, UserProfile } from 'types'
import { log, LogType, npubToHex } from 'utils'
export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile
isBlocked: boolean
isOwnProfile: boolean
muteLists: {
admin: MuteLists
user: MuteLists
}
nsfwList: string[]
}
export const profileRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
// Try to decode nprofile parameter
const { nprofile } = params
let profilePubkey: string | undefined
try {
// Decode if it starts with nprofile1
if (nprofile?.startsWith('nprofile1')) {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} else if (nprofile?.startsWith('npub1')) {
// Try to get hex from the npub and encode it to nprofile
const value = npubToHex(nprofile)
if (value) {
return redirect(
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: value
})
)
)
}
}
} catch (error) {
// Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error)
}
// Get the current state
const userState = store.getState().user
// Check if current user is logged in
let userPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
userPubkey = userState.user.pubkey as string
}
// Redirect if profile naddr is missing
// - home if user is not logged
let profileRoute = appRoutes.home
if (!profilePubkey && userPubkey) {
// - own profile
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userPubkey
})
)
}
if (!profilePubkey) return redirect(profileRoute)
// Empty result
const result: ProfilePageLoaderResult = {
profilePubkey: profilePubkey,
profile: {},
isBlocked: false,
isOwnProfile: false,
muteLists: {
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
},
nsfwList: []
}
// Check if user the user is logged in
if (userState.auth && userState.user?.pubkey) {
result.isOwnProfile = userState.user.pubkey === profilePubkey
}
const settled = await Promise.allSettled([
ndkContext.findMetadata(profilePubkey),
ndkContext.getMuteLists(userPubkey),
ndkContext.getNSFWList()
])
// Check the profile event result
const profileEventResult = settled[0]
if (profileEventResult.status === 'fulfilled' && profileEventResult.value) {
result.profile = profileEventResult.value
} else if (profileEventResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch profile.',
profileEventResult.reason
)
}
// Check the profile event result
const muteListResult = settled[1]
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
result.muteLists = muteListResult.value
// Check if user has blocked this profile
result.isBlocked = result.muteLists.user.authors.includes(profilePubkey)
} else if (muteListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelist.',
muteListResult.reason
)
}
// Check the profile event result
const nsfwListResult = settled[2]
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
result.nsfwList = nsfwListResult.value
} else if (nsfwListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelist.',
nsfwListResult.reason
)
}
return result
}

View File

@ -4,15 +4,16 @@ import {
NDKKind,
NDKSubscriptionCacheUsage,
NDKUserProfile,
NostrEvent,
profileFromEvent
} from '@nostr-dev-kit/ndk'
import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection'
import { SearchInput } from 'components/SearchInput'
import {
MAX_GAMES_PER_PAGE,
MAX_MODS_PER_PAGE,
@ -22,32 +23,18 @@ import {
useAppSelector,
useFilteredMods,
useGames,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
import {
FilterOptions,
ModDetails,
ModeratedFilter,
MuteLists,
NSFWFilter,
SortBy
} from 'types'
import {
DEFAULT_FILTER_OPTIONS,
extractModData,
isModDataComplete,
log,
LogType,
scrollIntoView
} from 'utils'
@ -59,30 +46,35 @@ enum SearchKindEnum {
export const SearchPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchKind, setSearchKind] = useState(
(searchParams.get('searching') as SearchKindEnum) || SearchKindEnum.Mods
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [searchTerm, setSearchTerm] = useState(
searchParams.get('searchTerm') || ''
)
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
})
}
// Handle "Enter" key press inside the input
@ -109,42 +101,14 @@ export const SearchPage = () => {
</span>
</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
<SearchInput
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
ref={searchTermRef}
/>
</div>
</div>
<Filters
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
searchKind={searchKind}
setSearchKind={setSearchKind}
/>
<Filters />
{searchKind === SearchKindEnum.Mods && (
<ModsResult
searchTerm={searchTerm}
@ -170,73 +134,29 @@ export const SearchPage = () => {
)
}
type FiltersProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
searchKind: SearchKindEnum
setSearchKind: Dispatch<SetStateAction<SearchKindEnum>>
}
const Filters = React.memo(() => {
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const Filters = React.memo(
({
filterOptions,
setFilterOptions,
searchKind,
setSearchKind
}: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
const userState = useAppSelector((state) => state.user)
const [searchParams, setSearchParams] = useSearchParams()
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
const handleChangeSearchKind = (kind: SearchKindEnum) => {
searchParams.set('kind', kind)
setSearchParams(searchParams, {
replace: true
})
}
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{searchKind === SearchKindEnum.Mods && (
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
)}
{searchKind === SearchKindEnum.Users && (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
)}
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{searchKind === SearchKindEnum.Mods && <ModFilter />}
{searchKind === SearchKindEnum.Users && (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
@ -245,26 +165,65 @@ const Filters = React.memo(
data-bs-toggle='dropdown'
type='button'
>
Searching: {searchKind}
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SearchKindEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() => setSearchKind(item)}
>
{item}
</div>
))}
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
)}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Searching: {searchKind}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SearchKindEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() => handleChangeSearchKind(item)}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
)
}
)
</div>
)
})
type ModsResultProps = {
filterOptions: FilterOptions
@ -324,6 +283,7 @@ const ModsResult = ({
}, [searchTerm])
const filteredMods = useMemo(() => {
// Search page requires search term
if (searchTerm === '') return []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
@ -337,8 +297,16 @@ const ModsResult = ({
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn)
}, [mods, searchTerm])
const filterSourceFn = (mod: ModDetails) => {
// Filter by source if selected
if (filterOptions.source === window.location.host) {
return mod.rTag === filterOptions.source
}
return true
}
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
const filteredModList = useFilteredMods(
filteredMods,
@ -393,39 +361,70 @@ const UsersResult = ({
moderationFilter,
muteLists
}: UsersResultProps) => {
const { fetchEvents } = useNDKContext()
const [isFetching, setIsFetching] = useState(false)
const { ndk } = useNDKContext()
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (searchTerm === '') {
setProfiles([])
} else {
const filter: NDKFilter = {
kinds: [NDKKind.Metadata],
search: searchTerm
const sub = ndk.subscribe(
{
kinds: [NDKKind.Metadata],
search: searchTerm
},
{
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
},
undefined,
false
)
// Stop the sub after 10 seconds if we are still searching the same term as before
window.setTimeout(() => {
if (sub.filter.search === searchTerm) {
sub.stop()
}
}, 10000)
const onEvent = (event: NostrEvent | NDKEvent) => {
if (!(event instanceof NDKEvent)) event = new NDKEvent(undefined, event)
const dedupKey = event.deduplicationKey()
const existingEvent = events.get(dedupKey)
if (existingEvent) {
event = dedup(existingEvent, event)
}
event.ndk = this
events.set(dedupKey, event)
// We can't rely on the 'eose' to arrive
// Instead we repeat and sort results on each event
const ndkEvents = Array.from(events.values())
const profiles: NDKUserProfile[] = []
ndkEvents.forEach((event) => {
try {
const profile = profileFromEvent(event)
profiles.push(profile)
} catch (error) {
// If we are unable to parse silently skip over the errors
}
})
setProfiles(profiles)
}
setIsFetching(true)
fetchEvents(filter)
.then((events) => {
const results = events.map((event) => {
const ndkEvent = new NDKEvent(undefined, event)
const profile = profileFromEvent(ndkEvent)
return profile
})
setProfiles(results)
})
.catch((err) => {
log(true, LogType.Error, 'An error occurred in fetching users', err)
})
.finally(() => {
setIsFetching(false)
})
// Clear previous results
const events = new Map<string, NDKEvent>()
// Bind handler and start the sub
sub.on('event', onEvent)
sub.start()
return () => {
sub.stop()
}
}
}, [searchTerm, fetchEvents])
}, [ndk, searchTerm])
const filteredProfiles = useMemo(() => {
let filtered = [...profiles]
@ -450,25 +449,13 @@ const UsersResult = ({
}, [userState.user?.npub, moderationFilter, profiles, muteLists])
return (
<>
{isFetching && <LoadingSpinner desc='Fetching Profiles' />}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredProfiles.map((profile) => {
if (profile.pubkey) {
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
return (
<ErrorBoundary key={profile.pubkey}>
<Profile
pubkey={profile.pubkey as string}
displayName={displayName}
about={about}
image={profile?.image}
nip05={profile?.nip05}
lud16={profile?.lud16}
/>
<Profile pubkey={profile.pubkey as string} />
</ErrorBoundary>
)
}
@ -536,3 +523,11 @@ const GamesResult = ({ searchTerm }: GamesResultProps) => {
</>
)
}
function dedup(event1: NDKEvent, event2: NDKEvent) {
// return the newest of the two
if (event1.created_at! > event2.created_at!) {
return event1
}
return event2
}

View File

@ -98,15 +98,15 @@ export const ProfileSettings = () => {
// In case user is not logged in clicking on profile link will navigate to homepage
let profileRoute = appRoutes.home
let nprofile: string | undefined
if (userState.auth && userState.user) {
const hexPubkey = npubToHex(userState.user.npub as string)
if (hexPubkey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: hexPubkey
})
)
nprofile = nip19.nprofileEncode({
pubkey: hexPubkey
})
profileRoute = getProfilePageRoute(nprofile)
}
}
@ -247,10 +247,8 @@ export const ProfileSettings = () => {
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
{typeof userState.user?.pubkey === 'string' && (
<ProfileQRButtonWithPopUp
pubkey={userState.user.pubkey}
/>
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
</div>
</div>

View File

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

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

@ -0,0 +1,189 @@
import { NDKContextType } from 'contexts/NDKContext'
import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { getBlogPageRoute } from 'routes'
import { BlogFormErrors, BlogEventSubmitForm, BlogEventEditForm } from 'types'
import {
isReachable,
isValidImageUrl,
log,
LogType,
now,
parseFormData
} from 'utils'
import TurndownService from 'turndown'
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
import { toast } from 'react-toastify'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { v4 as uuidv4 } from 'uuid'
import { store } from 'store'
export const writeRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
// Get the current state
const userState = store.getState().user
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
if (error instanceof Error) {
log(true, LogType.Error, 'Failed to get public key.', error)
}
toast.error('Failed to get public key.')
return null
}
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
return null
}
// Get the form data from submit request
const formData = await request.formData()
// Parse the the data
const formSubmit = parseFormData<BlogEventSubmitForm | BlogEventEditForm>(
formData
)
// Check for errors
const formErrors = await validateFormData(formSubmit)
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Get the markdown from the html
const turndownService = new TurndownService()
const content = turndownService.turndown(formSubmit.content!)
// Check if we are editing or this is a new blog
const { naddr } = params
const isEditing =
naddr && request.method === 'PUT' && isEditForm(formSubmit)
const formEdit = isEditing ? formSubmit : undefined
const currentTimeStamp = now()
// Get the existing edited fields or new ones
const uuid = isEditing && formEdit?.dTag ? formSubmit.dTag : uuidv4()
const rTag =
isEditing && formEdit?.rTag ? formEdit.rTag : window.location.host
const published_at =
isEditing && formEdit?.published_at
? formEdit.published_at
: currentTimeStamp
const aTag = `${kinds.LongFormArticle}:${hexPubkey}:${uuid}`
const tTags = formSubmit
.tags!.toLowerCase()
.split(',')
.map((t) => ['t', t])
const tags = [
['d', uuid],
['a', aTag],
['r', rTag],
['published_at', published_at.toString()],
['title', formSubmit.title!],
['image', formSubmit.image!],
['summary', formSubmit.summary!],
...tTags
]
// Add NSFW tag, L label namespace standardized tag
// https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags
if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning'])
const unsignedEvent: UnsignedEvent = {
kind: kinds.LongFormArticle,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: content,
tags: tags
}
try {
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
if (!signedEvent) {
toast.error('Failed to sign the event!')
return null
}
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
const publishedOnRelays = await ndkContext.publish(ndkEvent)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay.')
return null
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
const naddr = nip19.naddrEncode({
identifier: uuid,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
return redirect(getBlogPageRoute(naddr))
}
} catch (error) {
log(true, LogType.Error, 'Failed to sign the event!', error)
toast.error('Failed to sign the event!')
return null
}
}
const validateFormData = async (
formData: Partial<BlogEventSubmitForm>
): Promise<BlogFormErrors> => {
const errors: BlogFormErrors = {}
if (!formData.title || formData.title.trim() === '') {
errors.title = 'Title field can not be empty'
}
if (
!formData.content ||
formData.content.trim() === '' ||
formData.content.trim() === '<p></p>'
) {
errors.content = 'Content field can not be empty'
}
if (!formData.summary || formData.summary.trim() === '') {
errors.summary = 'Summary field can not be empty'
}
if (!formData.tags || formData.tags.trim() === '') {
errors.tags = 'Tags field can not be empty'
}
if (!formData.image || formData.image.trim() === '') {
errors.image = 'Image url field can not be empty'
} else if (
!isValidImageUrl(formData.image) ||
!(await isReachable(formData.image))
) {
errors.image = 'Image must be a valid and reachable'
}
return errors
}
function isEditForm(
form: Partial<BlogEventSubmitForm | BlogEventEditForm>
): form is BlogEventEditForm {
return (form as BlogEventEditForm).dTag !== undefined
}

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

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

View File

@ -1,25 +1,42 @@
import { SearchPage } from 'pages/search'
import { createBrowserRouter } from 'react-router-dom'
import { NDKContextType } from 'contexts/NDKContext'
import { Layout } from 'layout'
import { SearchPage } from '../pages/search'
import { AboutPage } from '../pages/about'
import { BlogsPage } from '../pages/blogs'
import { GamesPage } from '../pages/games'
import { HomePage } from '../pages/home'
import { ModPage } from '../pages/mod'
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 { 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: '/',
home: '/home',
home: '/',
games: '/games',
game: '/game/:name',
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',
@ -28,7 +45,9 @@ export const appRoutes = {
settingsRelays: '/settings-relays',
settingsPreferences: '/settings-preferences',
settingsAdmin: '/settings-admin',
profile: '/profile/:nprofile'
profile: '/profile/:nprofile?',
feed: '/feed',
notifications: '/notifications'
}
export const getGamePageRoute = (name: string) =>
@ -40,76 +59,119 @@ export const getModPageRoute = (eventId: string) =>
export const getModsEditPageRoute = (eventId: string) =>
appRoutes.editMod.replace(':naddr', eventId)
export const getBlogPageRoute = (eventId: string) =>
appRoutes.blog.replace(':naddr', eventId)
export const getProfilePageRoute = (nprofile: string) =>
appRoutes.profile.replace(':nprofile', nprofile)
export const routes = [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.home,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blog,
element: <BlogsPage />
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
}
]
export const routerWithNdkContext = (context: NDKContextType) =>
createBrowserRouter([
{
element: <Layout />,
children: [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blogs,
element: <BlogsPage />,
loader: blogsRouteLoader(context)
},
{
path: appRoutes.blog,
element: <BlogPage />,
loader: blogRouteLoader(context),
action: blogRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogEdit,
element: <WritePage key='edit' />,
loader: blogRouteLoader(context),
action: writeRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogReport_actionOnly,
action: blogReportRouteAction(context)
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage key='write' />,
action: writeRouteAction(context)
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />,
loader: profileRouteLoader(context)
},
{
element: <FeedLayout />,
children: [
{
path: appRoutes.feed,
element: <FeedPage />
},
{
path: appRoutes.notifications,
element: <NotificationsPage />
}
]
},
{
path: '*',
element: <NotFoundPage />
}
]
}
])

View File

@ -1,18 +1,19 @@
.IBMSMSMSSS_Author {
padding: 10px;
display: grid;
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
grid-gap: 10px;
border: solid 1px rgba(255,255,255,0.1);
border-radius: 15px;
}
.IBMSMSMSSS_Author_Top {
display: grid;
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
grid-gap: 15px;
justify-content: start;
align-items: start;
flex-grow: 1;
}
@media (max-width: 576px) {
@ -32,9 +33,11 @@
}
.IBMSMSMSSS_Author_Top_Details {
display: grid;
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
grid-gap: 15px;
flex-grow: 1;
width: 100%;
}
.HBSS_Author_Top_NostrLinks {
@ -127,7 +130,6 @@
}
.IBMSMSMSSS_Author_Top_Left {
height: 100%;
display: flex;
flex-direction: column;
grid-gap: 15px;
@ -136,7 +138,6 @@
@media (max-width: 576px) {
.IBMSMSMSSS_Author_Top_Left {
height: 100%;
display: flex;
flex-direction: column;
grid-gap: 25px;

View File

@ -46,3 +46,20 @@
backdrop-filter: blur(5px);
}
.cardBlogMainInsideTitle {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 20px;
line-height: 1.5;
color: rgba(255,255,255,0.75);
text-shadow: 0 0 8px rgba(0,0,0,0.25);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard.IBMSMSMBSSTagsTagNSFWCardAlt {
position: absolute;
top: 10px;
right: 10px;
bottom: unset;
}

View File

@ -155,3 +155,12 @@
backdrop-filter: blur(10px);
background: rgba(35, 35, 35, 0.85);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagRepost.IBMSMSMBSSTagsTagRepostCard {
position: absolute;
bottom: 10px;
left: 10px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background: #232323d9;
}

View File

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

View File

@ -229,3 +229,12 @@
color: rgba(255,255,255,0.5);
}
.dropdown.dropdownMain.dropdownMainBlogpost {
flex-grow: unset;
position: absolute;
top: 10px;
right: 10px;
background: #0000002e;
border-radius: 6px;
padding: 2px;
}

View File

@ -22,6 +22,5 @@
flex-wrap: wrap;
grid-gap: 10px;
padding: 10px 0 0 0;
border-top: solid 1px rgba(255,255,255,0.1);
border-top: solid 1px rgba(255, 255, 255, 0.1);
}

View File

@ -44,3 +44,13 @@
cursor: default;
box-shadow: unset;
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagRepost {
background: #ffffff1a;
color: #ffffff59;
font-weight: 700;
border: unset;
font-size: 14px;
cursor: default;
box-shadow: unset;
}

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

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

View File

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

View File

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

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

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

8
src/utils/consts.ts Normal file
View File

@ -0,0 +1,8 @@
import { FilterOptions, SortBy, NSFWFilter, ModeratedFilter } from 'types'
export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
}

View File

@ -3,3 +3,6 @@ export * from './nostr'
export * from './url'
export * from './utils'
export * from './zap'
export * from './localStorage'
export * from './consts'
export * from './blog'

32
src/utils/localStorage.ts Normal file
View File

@ -0,0 +1,32 @@
export function getLocalStorageItem<T>(key: string, defaultValue: T): string {
try {
const data = window.localStorage.getItem(key)
if (data === null) return JSON.stringify(defaultValue)
return data
} catch (err) {
console.error(`Error while fetching local storage value: `, err)
return JSON.stringify(defaultValue)
}
}
export function setLocalStorageItem(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
dispatchLocalStorageEvent(key, value)
} catch (err) {
console.error(`Error while saving local storage value: `, err)
}
}
export function removeLocalStorageItem(key: string) {
try {
window.localStorage.removeItem(key)
dispatchLocalStorageEvent(key, null)
} catch (err) {
console.error(`Error while deleting local storage value: `, err)
}
}
function dispatchLocalStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }))
}

View File

@ -65,6 +65,38 @@ export const getTagValue = (
return null
}
export const getTagValues = (
event: Event | NDKEvent,
tagIdentifier: string
): string[] | null => {
// Find the tag in the event's tags array where the first element matches the tagIdentifier.
const tags = event.tags.filter((item) => item[0] === tagIdentifier)
// If a matching tag is found, return the rest of the elements in the tag (i.e., the values).
if (tags && tags.length) {
return tags.map((item) => item[1]) // Returning only the values
}
// Return null if no matching tag is found.
return null
}
export const getFirstTagValue = (
event: Event | NDKEvent,
tagIdentifier: string
) => {
const tags = getTagValues(event, tagIdentifier)
return tags && tags.length ? tags[0] : undefined
}
export const getFirstTagValueAsInt = (
event: Event | NDKEvent,
tagIdentifier: string
) => {
const value = getFirstTagValue(event, tagIdentifier)
return value ? parseInt(value, 10) : undefined
}
/**
* @param hexKey hex private or public key
* @returns whether or not is key valid

View File

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

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

@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_REPORTING_NPUB: string
readonly VITE_FALLBACK_MOD_IMAGE: string
readonly VITE_FALLBACK_GAME_IMAGE: string
readonly VITE_BLOG_NPUBS: string
// more env variables...
}