Compare commits
507 Commits
mods-inner
...
staging
Author | SHA1 | Date | |
---|---|---|---|
c38d14a633 | |||
38bd029687 | |||
f29a2634fd | |||
61a94e5358 | |||
f05f0dc1ea | |||
c9ceed6c0f | |||
|
a241f90269 | ||
|
a486e5a383 | ||
|
8d20678c75 | ||
|
994382f39c | ||
54ab35e78c | |||
|
02a81213a2 | ||
|
8b5b9a6e30 | ||
|
2936d6d53b | ||
|
4b6926b0b9 | ||
990f91c0a6 | |||
|
81d012b0cb | ||
1b960e5f02 | |||
|
4b6db36646 | ||
0b2de940d0 | |||
cea403d212 | |||
|
4f8cac6eee | ||
432d182d15 | |||
|
870262fcdc | ||
|
6e4e580402 | ||
f3ab7f6d6a | |||
9a30eae749 | |||
79ef25cb3b | |||
|
77c2e880f3 | ||
59c1171260 | |||
7a5128c802 | |||
ebf0b5aa13 | |||
e0a3b3b286 | |||
8f7a85cf0a | |||
56696129d6 | |||
2de5cd52b6 | |||
|
33635194fc | ||
48368e469e | |||
|
940f400300 | ||
11de23b7d2 | |||
a912e3e43c | |||
df0c64e2c9 | |||
|
2ed81c857c | ||
|
18bbc12776 | ||
|
f7d21807a4 | ||
|
cd3c7ace01 | ||
|
aaffc56424 | ||
|
7b1a70446d | ||
|
4140438044 | ||
|
d6bc3b8684 | ||
296b0ad61d | |||
|
0f874c6bbb | ||
|
297de3999c | ||
|
49435c2b50 | ||
|
718350d2bc | ||
|
3ee9e313de | ||
|
91830a539a | ||
|
834701aa2c | ||
0c7e61cadd | |||
|
b49ae9537b | ||
|
352179f1d9 | ||
|
77b6aa0d75 | ||
|
2c31c279a1 | ||
|
7be41272a0 | ||
cea814676e | |||
bd6515ca53 | |||
fedd7dd463 | |||
7ceb109bab | |||
b3747e9c22 | |||
d10b10a4fb | |||
|
ad197fdd62 | ||
|
d854622d25 | ||
|
0aac63d968 | ||
2fe0a79009 | |||
8af73df889 | |||
0d10890ca3 | |||
4176b06bee | |||
|
178876ab99 | ||
|
1c1430ba5c | ||
|
2f563e1bfb | ||
|
f7f3764686 | ||
f734b1447f | |||
|
6d6ff8ce43 | ||
31fd4ddfb5 | |||
|
f30ac01ea6 | ||
|
31ee0221b7 | ||
|
c81b2c0a1d | ||
|
dae94733fa | ||
|
2f32f400dd | ||
|
1d0f27d255 | ||
|
a3bec707b0 | ||
|
169ab37304 | ||
|
73a7b1c1ee | ||
|
847aab29d7 | ||
|
3717c3bfb9 | ||
|
c2413e1bd8 | ||
|
d4148ed01d | ||
7f0431f8f8 | |||
3a440d5479 | |||
40dd903e97 | |||
68ebaf38bd | |||
36aeb53a8c | |||
|
c25d3da64b | ||
|
4eb8c7d653 | ||
43c8ae4066 | |||
49c1168bb7 | |||
69768388e4 | |||
80172aee07 | |||
|
0f6cd4a9bd | ||
|
35fdf2c8b7 | ||
e41ce32ef2 | |||
|
0ee3dba906 | ||
|
efad0f44f5 | ||
|
6e07f4b8be | ||
|
7640bdd53b | ||
|
72252d416b | ||
|
6e4fa104c0 | ||
f2f80a36c6 | |||
|
4f4e3a7c85 | ||
|
0b1d43eac4 | ||
|
2dc0ab6cf4 | ||
|
15af98359d | ||
|
3906c70bc9 | ||
|
9341cd6544 | ||
bc782c775a | |||
|
d3c2d5fe7a | ||
|
d96e5088b8 | ||
|
9aa57c1adf | ||
|
8ee6f98654 | ||
|
84cb5b6912 | ||
9b8bf01d33 | |||
|
53861a36d3 | ||
|
99ce338502 | ||
|
76478ad572 | ||
|
7393940027 | ||
|
bb653fa356 | ||
|
2e367ecde8 | ||
|
a95cd8b6ec | ||
|
8810673492 | ||
|
a97a034178 | ||
|
0102f41403 | ||
63333b38c3 | |||
|
38280d151a | ||
16d39c407d | |||
b10920597f | |||
|
2dd261161e | ||
0e8eeb13d1 | |||
|
4bf18f1584 | ||
988cc03f37 | |||
|
ffc7b60363 | ||
|
545e6e6ec0 | ||
|
b0ebe7154a | ||
|
b69be4d755 | ||
04f32546f2 | |||
|
9aeee018b3 | ||
|
e3b6aecfe8 | ||
|
4214fe127f | ||
|
4bd7c77c05 | ||
|
82b87b3e32 | ||
|
9d50cdfd88 | ||
7f66c17f90 | |||
|
59f4fd6b29 | ||
610801a674 | |||
2bdee74ac8 | |||
7f8716ab59 | |||
37e9618cf9 | |||
|
4d9c68d741 | ||
90efd672cd | |||
|
9c580c5ef5 | ||
|
82b4774174 | ||
|
4dd8473f70 | ||
|
94d976f127 | ||
|
128a2fd82a | ||
|
83b93f08db | ||
|
701925b91e | ||
|
755014103c | ||
|
69331ba19e | ||
|
e610148e01 | ||
|
2c49dd0c32 | ||
|
3d064113c5 | ||
|
43bddd98fc | ||
|
7ee414caa5 | ||
|
98d35f5b7f | ||
|
78ca820ad4 | ||
|
67f06a0717 | ||
|
729956c1fc | ||
|
b96ff6ab4c | ||
|
8529a95718 | ||
|
44acba8d26 | ||
|
73e2868f52 | ||
|
601eda18d3 | ||
|
c65b49720f | ||
|
c4ef3060be | ||
|
c022644bcd | ||
|
b48b4478af | ||
|
b6a8fc435d | ||
|
2abd940c00 | ||
|
d05fd5f9e6 | ||
|
e6a465d54f | ||
|
486f3b79a5 | ||
|
6140218e80 | ||
|
6c49fd7a0a | ||
|
0f18e2ce8d | ||
|
56f5951a36 | ||
|
1d4d302335 | ||
|
440a2c6be1 | ||
|
98ef58884e | ||
|
bad1404e1a | ||
|
3ed7eada83 | ||
|
3ff4437d44 | ||
|
3b2e899c14 | ||
|
35c1d704e0 | ||
|
27933afdea | ||
|
c8a9c3c736 | ||
|
185df7ba6d | ||
|
f7bf65c845 | ||
|
f4f9a8bd17 | ||
|
8608765e79 | ||
|
37457128c5 | ||
|
26accf4eca | ||
|
7002e3e18d | ||
|
2b23294ae5 | ||
|
a359ee7a8f | ||
|
1a889213fa | ||
|
e490b35a5f | ||
|
a6e248d908 | ||
|
70a03d8b63 | ||
|
da702898f8 | ||
|
09f3307635 | ||
|
56c45f5c57 | ||
|
ceeb6d22d0 | ||
|
a0d2c811db | ||
|
c0adc8f09b | ||
|
b21c79b992 | ||
|
a10e9aafd1 | ||
|
a84608a8f9 | ||
|
8659934f6c | ||
|
5db234ffda | ||
|
e2f0f49374 | ||
|
7c7f756fb4 | ||
|
b10fe68a2d | ||
|
c59f48611c | ||
|
5ef9cf83d2 | ||
|
6dcb7e93c6 | ||
|
6a087e659c | ||
|
d11ad60ede | ||
|
d171f03f29 | ||
|
e22c731ec6 | ||
|
64b5c7194f | ||
|
ec4c434b66 | ||
|
59efa91677 | ||
|
1960589fc3 | ||
|
0250f6dc11 | ||
|
dc91a6a186 | ||
|
1d02bf0d6f | ||
|
a1dd002d28 | ||
|
72cbd325b0 | ||
|
05414013ce | ||
|
c62c1a29b9 | ||
|
381028614a | ||
|
876f986ea5 | ||
|
b9d5bb8211 | ||
|
4dc65b92f7 | ||
|
9e8aa16297 | ||
|
a90e932ed6 | ||
|
9730fec14f | ||
|
06f0282cad | ||
|
5b641ff4cc | ||
|
49ed027b5c | ||
|
aa8d18ea53 | ||
|
f708dd6530 | ||
|
e3f49832f2 | ||
|
d76676c089 | ||
|
6b1d4e7322 | ||
|
05adb00072 | ||
|
4175ebc010 | ||
|
3a71a4a297 | ||
|
d3a93eab3e | ||
|
22fc2b4ba3 | ||
|
d70e302a69 | ||
|
8b93d0506d | ||
|
a56d26387e | ||
|
d13c7ca6c3 | ||
|
34b096b121 | ||
|
53d47fcb80 | ||
|
7a1d0bbfb0 | ||
|
d15c5a21d9 | ||
|
990460d7cf | ||
|
96bf84a0c4 | ||
|
aa0b9cf3c3 | ||
|
b808157352 | ||
|
511a67b793 | ||
|
5ed6a51e76 | ||
|
4d5f132bab | ||
|
50540f0e3f | ||
|
e31e7d85ac | ||
|
a3a022c436 | ||
|
458ad744e4 | ||
|
d77d9f7bcc | ||
|
23ad13fa85 | ||
|
87359a914e | ||
|
3d20163b08 | ||
|
0ac31675f9 | ||
|
98f4666f96 | ||
|
2dd2992810 | ||
|
a85314f0a7 | ||
|
b12887cdf5 | ||
|
3e3f5fe82b | ||
|
a661b3f781 | ||
|
1fde36bc5c | ||
|
77d849e3ab | ||
|
822d5110a8 | ||
|
03f9269eb6 | ||
|
4b51fa55f5 | ||
|
018536e11d | ||
|
c44a28f755 | ||
|
8fea6fa27f | ||
|
fad1ff98b3 | ||
|
4a7899cfde | ||
|
0c32999df8 | ||
|
b4465ee1c6 | ||
|
b492f97795 | ||
|
f13d800d85 | ||
|
8726d042f2 | ||
|
17ef110f6f | ||
|
764c936ff8 | ||
|
1b2926ae77 | ||
|
9a192451f6 | ||
|
16b3c7684b | ||
|
24ea309dd1 | ||
|
41240ee3fb | ||
|
098068acef | ||
|
56ec37e57b | ||
|
c6831f3fb2 | ||
|
0733849b25 | ||
|
faf30a89b0 | ||
|
0b2e5c29d5 | ||
|
e1da323c2f | ||
9893373f75 | |||
|
203e27b19d | ||
|
47cc4a19ea | ||
|
ab27a1f9e1 | ||
|
8a232c7d91 | ||
|
1e98b16c14 | ||
|
d9f0972961 | ||
|
aa9884b9fa | ||
|
5cb20794d0 | ||
4d64c33597 | |||
|
d9347014ec | ||
|
1259144228 | ||
|
a5018d9a1f | ||
|
e0440e1638 | ||
|
74e38eac50 | ||
|
eb450839d5 | ||
|
733c155447 | ||
|
9bdc8678c4 | ||
|
7c2dd9fe7a | ||
|
9782256483 | ||
|
1cd898eae7 | ||
|
926d29a36e | ||
|
29947757af | ||
|
b9b1e1457c | ||
|
a88ef61eb7 | ||
|
4de54f7688 | ||
|
c429dfa322 | ||
26dcd5463d | |||
|
1927887992 | ||
dc19e614df | |||
|
6377da94c6 | ||
|
848af66a75 | ||
|
aca6908ce9 | ||
|
be7e506457 | ||
|
b0f8c647ee | ||
|
b259623ab6 | ||
|
2a28b068b6 | ||
|
e335b05290 | ||
|
b1b0876238 | ||
|
d18528bcf6 | ||
|
7743f30faa | ||
|
8bd4373546 | ||
|
9a1cc39027 | ||
|
1e6b3d2c0b | ||
|
d9129ab4da | ||
|
47ce263c37 | ||
|
7b25e7653c | ||
|
4d7e69b089 | ||
|
3f873c410c | ||
|
42d20857f5 | ||
|
837be77d9c | ||
|
d83f19b1ca | ||
|
a89a9582fd | ||
|
a5fe2c1622 | ||
|
de21af1b4a | ||
|
54911d3efa | ||
|
1a52bfd30b | ||
|
1f93e9c6b6 | ||
|
23088cb3ec | ||
|
a5627000ae | ||
|
e40c900499 | ||
|
d8d04b8ae0 | ||
|
021b3fcece | ||
|
40630a6513 | ||
f8ca32c143 | |||
|
da88a969f3 | ||
60317c2e8c | |||
|
012e868ad3 | ||
|
46b0384bc6 | ||
|
3ddb95fda2 | ||
|
434fbd99c0 | ||
|
d7246e3cb0 | ||
|
d425b9ec87 | ||
|
d462c9bd93 | ||
|
3440cc0fb6 | ||
|
2222c78742 | ||
|
02843028f7 | ||
|
fd5a9e7e4f | ||
|
e4c09399ec | ||
|
50de8ef84c | ||
|
21ef8c61f4 | ||
|
0211e380a0 | ||
|
8395d4fc31 | ||
|
b73f3632df | ||
|
99463e3fd2 | ||
|
81fa41635c | ||
|
c0eab5dd13 | ||
|
9cea402479 | ||
|
00e73aaad8 | ||
|
acfdd21c4d | ||
|
a7b110503f | ||
|
a26453c16f | ||
|
658cf0ab23 | ||
|
8e791075da | ||
|
b9799c3924 | ||
|
a4789e7f5f | ||
|
3d230eddf7 | ||
|
241a1dcec9 | ||
|
16b8107731 | ||
|
cc31325bb7 | ||
|
f9498d8d8f | ||
|
3b594fb5eb | ||
|
b5fd5ded6a | ||
|
999301a4d9 | ||
d1f84ddd90 | |||
2cd972f0e1 | |||
|
17e28ede53 | ||
|
fc8bd16995 | ||
|
bdd44681d7 | ||
|
8e53b8af87 | ||
|
dd22b780a7 | ||
|
1d04002b8d | ||
|
f4ced90acb | ||
|
90581fe630 | ||
|
5771f81061 | ||
|
38f46bf01d | ||
|
0f688b2a59 | ||
|
a88a2d93b0 | ||
|
b6f5dfa720 | ||
|
5d890d86f6 | ||
|
a05c2e0164 | ||
|
d62b9b1c20 | ||
|
ed8ff79373 | ||
|
c01f3279cc | ||
|
8191534cf2 | ||
|
1eb25d1e72 | ||
|
f35ebe4bee | ||
|
83cc74606c | ||
|
a0d2dd48e9 | ||
24203884fb | |||
|
6152b895bc | ||
|
75d8839330 | ||
|
f544d8ab15 | ||
|
c978c50168 | ||
|
36f9f976ac | ||
|
62a8207bcd | ||
|
0c74bfbb58 | ||
|
144a091b7f | ||
|
0384dc668e | ||
|
929800a543 | ||
|
a68a906cbe | ||
|
6c6381791e | ||
|
9c2ec3a5ea | ||
|
563119e1a1 | ||
|
d22b007fd9 | ||
|
6acf4c3028 | ||
|
7fa59d6a52 | ||
|
f3dde26d88 | ||
|
0cc0d82e68 | ||
|
01af3fa72e | ||
|
e06cec84fb | ||
|
ff737e5486 | ||
|
51bd5a4d5e | ||
|
a8a2d3dbf3 | ||
|
42c40c2d8e | ||
|
f9735128c7 | ||
|
9e4b9472ee | ||
|
f1f75a42df | ||
|
a4c2b50a16 | ||
|
0668226df1 | ||
|
d451eb129d | ||
|
5796bb18e3 | ||
|
4b6b0b73d6 | ||
|
f06801d80b | ||
0591dfecd6 | |||
|
44d0100341 | ||
|
7b9c95c863 | ||
|
0e14336e8b | ||
c5b75c088e |
18
.env.example
@ -1,4 +1,20 @@
|
||||
# This relay will be used to publish/retrieve events along with other relays (user's relays, admin relays)
|
||||
VITE_APP_RELAY=wss://relay.degmods.com
|
||||
|
||||
# A comma separated list of npubs, Relay list will be extracted for these npubs and this relay list will be used to publish event
|
||||
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
||||
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
||||
|
||||
# A dedicated npub used for reporting mods, blogs, profile and etc.
|
||||
VITE_REPORTING_NPUB= <npub1...>
|
||||
|
||||
# A dedicated npub used for site WOT.
|
||||
VITE_SITE_WOT_NPUB= <npub1...>
|
||||
|
||||
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
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
|
||||
|
||||
# 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>
|
||||
|
42
.gitea/workflows/release-production.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
name: Release to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create .env File
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_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
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
42
.gitea/workflows/release-staging.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
name: Release to Staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create .env File
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_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
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server dev.degmods.com
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
86
.github/workflows/release-pages-production.yaml
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Release to DEG-Mods.github.io
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment:
|
||||
name: github-pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Create .env File
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
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
|
||||
cat .env
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: './dist'
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ vars.ORGANIZATION_NAME }}/${{ vars.REPOSITORY_NAME }}
|
||||
path: ${{ vars.REPOSITORY_NAME }}
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- name: Clear target repo
|
||||
run: |
|
||||
rm -rf ${{ vars.REPOSITORY_NAME }}/*
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist
|
||||
|
||||
- name: Prepare files
|
||||
run: |
|
||||
cp -r dist/* ${{ vars.REPOSITORY_NAME }}/
|
||||
echo ${{ vars.CUSTOM_DOMAIN }} > ${{ vars.REPOSITORY_NAME }}/CNAME
|
||||
touch ${{ vars.REPOSITORY_NAME }}/.nojekyll
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd ${{ vars.REPOSITORY_NAME }}
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git add .
|
||||
git commit -m 'Deploy from source repo'
|
||||
git push origin main
|
21
LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Freakoverse
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
96
README.md
@ -1,11 +1,91 @@
|
||||
### Local testing
|
||||
[DEG Mods](https://degmods.com)
|
||||
===============================
|
||||
|
||||
- clone the repo
|
||||
**DEG Mods** (Decentralized Game Mods) is a censorship-resistant platform for game mods, built on the Nostr protocol. It allows creators to thrive without the fear of censorship, bans, or losing their connection with fans. At DEG Mods, game mod creators and enthusiasts are empowered because, well, we literally can't mess with them.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
```
|
||||
Features
|
||||
--------
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
- **Never Get Your Game Mod Page Taken Down**: Enjoy peace of mind knowing that your content won't be removed or censored.
|
||||
- **Never Get Your Profile Banned**: Your profile remains active and accessible, without fear of bans.
|
||||
- **Never Lose Your Followers**: Maintain a steady connection with your followers and fans.
|
||||
- **Get 100% of Tips Revenue**: Keep all the revenue from tips without any cuts.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
To get started with DEG Mods, follow these steps:
|
||||
|
||||
1. **Clone the repository**:
|
||||
|
||||
bash
|
||||
|
||||
Copy code
|
||||
|
||||
`git clone <URL-of-repo>`
|
||||
|
||||
2. **Navigate to the project directory**:
|
||||
|
||||
bash
|
||||
|
||||
Copy code
|
||||
|
||||
`cd <project-directory>`
|
||||
|
||||
3. **Install dependencies**:
|
||||
|
||||
DEG Mods is a React project, so use `npm ci` to install the exact versions of dependencies as specified in `package-lock.json`:
|
||||
|
||||
bash
|
||||
|
||||
Copy code
|
||||
|
||||
`npm ci`
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
To run DEG Mods, follow these instructions:
|
||||
|
||||
1. **Start the development server**:
|
||||
|
||||
bash
|
||||
|
||||
Copy code
|
||||
|
||||
`npm run dev`
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
We welcome contributions from the community! To contribute to DEG Mods:
|
||||
|
||||
- **Submit Issues**: Report bugs, provide feedback, or request features.
|
||||
- **Submit Pull Requests**: Contribute code improvements or new features.
|
||||
|
||||
Screenshots
|
||||
-----------
|
||||
|
||||
Here are some screenshots of DEG Mods in action:
|
||||
|
||||
- ![Screenshot 1](https://image.nostr.build/9ea5f70a647581e5ea8855edcc2afb59b33195a3498c4052fa6350e414c03c50.jpg)
|
||||
- ![Screenshot 2](https://image.nostr.build/f9a74d52547e84e51934f03358e5c41a2f99b896f1b2369fc3f4027d0f1a0d6f.jpg)
|
||||
- ![Screenshot 3](https://image.nostr.build/cbd806c58a0f5e33b721cd9205c53aae64a422f271a8434b4d9bb8c3ba4e5c90.jpg)
|
||||
- ![Screenshot 4](https://image.nostr.build/374bb4ca83211fd1f5646b611af188a424ebb1b44df1cb7ad29208868ac12675.jpg)
|
||||
|
||||
Nostr Implementations
|
||||
---------------------
|
||||
|
||||
- ✅ **Text**
|
||||
- ⬜ **Text**
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
This project is licensed under the MIT License. See the `LICENSE` file for more details.
|
||||
|
||||
Acknowledgments
|
||||
----------------
|
||||
|
||||
- **Nostr Protocol**: Thanks to the Nostr community for the foundational technology that makes DEG Mods possible.
|
||||
- **Contributors**: A shout-out to everyone who has contributed to making DEG Mods a reality.
|
||||
|
83
index.html
@ -3,50 +3,57 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta property="og:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css"
|
||||
/>
|
||||
|
||||
<!-- Links and Stylesheets -->
|
||||
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
<link rel="icon" type="image/png" sizes="935x934" href="/index.html" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.js"></script>
|
||||
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
1625
package-lock.json
generated
32
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "degmods.com",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.0-alpha-1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -10,32 +10,53 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "2.8.2",
|
||||
"@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.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",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dexie": "4.0.8",
|
||||
"dompurify": "3.1.6",
|
||||
"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",
|
||||
"qrcode.react": "3.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-quill": "2.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-toastify": "10.0.5",
|
||||
"react-window": "1.8.10",
|
||||
"uuid": "10.0.0"
|
||||
"swiper": "11.1.11",
|
||||
"turndown": "^7.2.0",
|
||||
"uuid": "10.0.0",
|
||||
"webln": "0.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/fslightbox-react": "1.7.7",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/papaparse": "5.3.14",
|
||||
"@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",
|
||||
@ -45,6 +66,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1"
|
||||
"vite": "^5.3.1",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
13
public/.well-known/nostr.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"names": {
|
||||
"degmods": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
|
||||
"degmodsreposter": "7382a4cc21742ac3e3581f1c653a41f912e985e6a941439377803b866042e53f",
|
||||
"degmodsreport": "ca2734bb5e59427dd5d66f59dde3b4a045110b7a12eb99a4a862bf012b7850d9",
|
||||
"freakoverse": "3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40",
|
||||
"nostrdev": "27487c9600b16b24a1bfb0519cfe4a5d1ad84959e3cce5d6d7a99d48660a1f78",
|
||||
"Merlin": "76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698",
|
||||
"makano": "fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10",
|
||||
"reya": "126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
|
||||
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede"
|
||||
}
|
||||
}
|
51
public/404.html
Normal 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>
|
203171
public/assets/games.csv
Before Width: | Height: | Size: 277 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 100 KiB |
Before Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 382 KiB |
@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M346.26,1070.9c-5.53,0-11.05-.24-16.55-.71-33.85-2.91-65.97-14.97-92.87-34.87-35.12-25.99-61.25-64.4-77.65-114.16-17.6-53.39-23.73-119.98-18.23-197.91,1.51-21.43,20.11-37.59,41.55-36.07,21.43,1.51,37.58,20.12,36.07,41.55-4.79,67.78,.1,124.33,14.52,168.07,11.27,34.18,28.1,59.74,50.03,75.97,34.85,25.79,77.61,23.3,106,12.39,41.25-15.87,71.89-36.44,60.28-142.57-12.19-111.45-69.11-296.1-190.35-617.42-16.35-43.34-31.68-85.14-43.17-117.72-5.03-14.26-9.14-26.25-11.89-34.67-1.47-4.5-2.55-7.97-3.3-10.63-.56-1.97-.97-3.58-1.28-5.07-3.55-16.78,3.5-28.44,8.03-33.89,8.93-10.76,22.99-15.92,36.76-13.49,7.31,1.29,20.79,5.93,28.98,22.47,.8,1.61,1.68,3.56,2.78,6.12,1.81,4.22,4.3,10.37,7.41,18.27,6.7,17.06,16.11,41.86,27.95,73.7,1.02,2.74,2.08,5.6,3.19,8.59,64.44-31.89,167.78-43.6,251.31-8.8,45.84,19.1,90.93,53.01,126.97,95.48,36.25,42.72,61.23,91.6,70.34,137.64,11.65,58.88-2.72,112.1-40.46,149.84-6.53,6.53-13.68,12.32-21.3,17.41,12.47,3.98,24.96,8.57,37.38,13.91,35.49,15.26,67.23,35.55,94.33,60.32,30.81,28.15,56.9,63.18,77.54,104.12,19.42,38.51,29.64,78.31,30.37,118.31,.7,38.28-7.71,75.44-24.31,107.45-28.81,55.55-80.46,92.61-141.7,101.67-54.5,8.07-100.82,3.51-141.61-13.93-38.31-16.38-70.85-43.81-99.49-83.86-2.54-3.56-5.04-7.19-7.49-10.9-10.31,65-45.42,105.7-109.31,130.27-22.52,8.66-46.71,13.12-70.8,13.12Zm121.68-501.85c5.5,16.67,10.85,33.08,16.15,49.32,39.13,119.89,70.03,214.59,113.07,274.78,41.66,58.26,89.87,77.39,166.41,66.07,36.76-5.44,66.6-26.93,84.01-60.52,22.75-43.86,20.33-101.77-6.46-154.9-69.22-137.27-190.86-151.43-298.18-163.92-26.34-3.07-51.75-6.02-74.99-10.83Zm-32-94.3c2.47,.39,5.26,.86,8.41,1.38,24.74,4.11,70.78,11.76,117.62,11.18,50.54-.62,87.08-10.76,105.65-29.33,19.34-19.34,25.79-46.16,19.15-79.71-13.72-69.37-80.01-146.85-150.91-176.39-66.87-27.87-153.56-13.19-194.29,10.66,21.21,58.09,50.17,138.2,88.82,246.56,1.87,5.25,3.72,10.47,5.54,15.65Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.1 KiB |
41
src/App.tsx
@ -1,21 +1,30 @@
|
||||
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() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{routes.map((route, index) => (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
const ndkContext = useNDKContext()
|
||||
|
||||
useEffect(() => {
|
||||
// Find the element with id 'root'
|
||||
const rootElement = document.getElementById('root')
|
||||
|
||||
if (rootElement) {
|
||||
// Add the class to the element
|
||||
rootElement.classList.add('bodyMain')
|
||||
}
|
||||
|
||||
// Cleanup function (optional): Remove the class when the component unmounts
|
||||
return () => {
|
||||
if (rootElement) {
|
||||
rootElement.classList.remove('bodyMain')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
3
src/assets/games/Games_Itch.csv
Normal file
@ -0,0 +1,3 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Voices of the Void,,https://image.nostr.build/472949882d0756c84d3effd9f641b10c88abd48265f0f01f360937b189d50b54.jpg
|
||||
Shroom and Gloom,,
|
|
2
src/assets/games/Games_Nintendo.csv
Normal file
@ -0,0 +1,2 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Fire Emblem Engage,,https://image.nostr.build/f9f883f88c7d1abc38b98b0aa2394684e52e10171b621011f348034ab9973476.jpg
|
|
7
src/assets/games/Games_Other.csv
Normal file
@ -0,0 +1,7 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
(Unlisted Game),,
|
||||
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
|
||||
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
||||
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
||||
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
|
||||
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
|
|
49991
src/assets/games/Games_Steam1.csv
Normal file
49999
src/assets/games/Games_Steam2.csv
Normal file
50000
src/assets/games/Games_Steam3.csv
Normal file
49994
src/assets/games/Games_Steam4.csv
Normal file
17227
src/assets/games/Games_Steam5.csv
Normal file
2
src/assets/games/Games_SteamManual.csv
Normal file
@ -0,0 +1,2 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@ -12,7 +12,7 @@
|
||||
<g>
|
||||
<path class="cls-1" d="M379.5,237.42V3.38h75.39c20.68,0,39.27,4.69,55.78,14.06,16.5,9.38,29.39,22.61,38.66,39.7,9.27,17.09,13.96,36.25,14.06,57.47v10.77c0,21.43-4.53,40.64-13.58,57.63-9.06,16.99-21.81,30.27-38.26,39.86-16.45,9.59-34.8,14.44-55.05,14.55h-77Zm56.42-190.48V194.02h19.61c16.18,0,28.61-5.76,37.29-17.28,8.68-11.52,13.02-28.64,13.02-51.36v-10.13c0-22.61-4.34-39.65-13.02-51.12-8.68-11.47-21.33-17.2-37.94-17.2h-18.97Z"/>
|
||||
<path class="cls-1" d="M737.47,138.08h-88.73v55.94h104.8v43.4h-161.22V3.38h161.55V46.94h-105.12v49.35h88.73v41.79Z"/>
|
||||
<path class="cls-1" d="M968.14,208.48c-8.68,9.64-21.38,17.42-38.1,23.31-16.72,5.89-35.04,8.84-54.97,8.84-30.65,0-55.13-9.38-73.46-28.13-18.32-18.75-28.13-44.85-29.42-78.28l-.16-20.25c0-23.04,4.07-43.16,12.22-60.36,8.14-17.2,19.8-30.43,34.96-39.7C834.37,4.64,851.92,0,871.85,0,901,0,923.64,6.67,939.77,20.01c16.13,13.34,25.53,33.25,28.21,59.72h-54.33c-1.93-13.07-6.11-22.4-12.54-27.97-6.43-5.57-15.54-8.36-27.33-8.36-14.14,0-25.08,6-32.79,18-7.72,12-11.63,29.15-11.73,51.44v14.14c0,23.36,3.99,40.91,11.98,52.64,7.98,11.73,20.55,17.6,37.69,17.6,14.68,0,25.61-3.27,32.79-9.81v-36.33h-39.22v-38.74h95.64v96.12Z"/>
|
||||
<path class="cls-1" d="M968.14,208.48c-8.68,9.64-21.38,17.42-38.1,23.31-16.72,5.89-35.04,8.84-54.97,8.84-30.65,0-55.13-9.38-73.46-28.13-18.32-18.75-28.13-44.85-29.42-78.28l-.16-20.25c0-23.04,4.07-43.16,12.22-60.36,8.14-17.2,19.8-30.43,34.96-39.7,15.16-9.27,32.71-13.9,52.64-13.9,29.15,0,51.78,6.67,67.91,20.01,16.13,13.34,25.53,33.25,28.21,59.72h-54.33c-1.93-13.07-6.11-22.4-12.54-27.97-6.43-5.57-15.54-8.36-27.33-8.36-14.14,0-25.08,6-32.79,18-7.72,12-11.63,29.15-11.73,51.44v14.14c0,23.36,3.99,40.91,11.98,52.64,7.98,11.73,20.55,17.6,37.69,17.6,14.68,0,25.61-3.27,32.79-9.81v-36.33h-39.22v-38.74h95.64v96.12Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M427.66,266.86l33.95,105.82,33.84-105.82h48.26v152.1h-36.77v-35.52l3.55-72.71-36.77,108.22h-24.23l-36.88-108.33,3.55,72.81v35.52h-36.67v-152.1h48.16Z"/>
|
||||
@ -22,11 +22,9 @@
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M228.02,300.66c9.1,0,16.48-7.38,16.48-16.48s-7.38-16.48-16.48-16.48-16.48,7.38-16.48,16.48,7.38,16.48,16.48,16.48Z"/>
|
||||
<path class="cls-1" d="M228.02,322.68c21.26,0,38.5-17.24,38.5-38.5s-17.24-38.5-38.5-38.5-38.5,17.24-38.5,38.5,17.24,38.5,38.5,38.5Zm0-65.72c14.92,0,27.01,12.09,27.01,27.01s-12.09,27.01-27.01,27.01-27.01-12.09-27.01-27.01,12.09-27.01,27.01-27.01Z"/>
|
||||
<path class="cls-1" d="M78.57,322.68c21.26,0,38.5-17.24,38.5-38.5s-17.24-38.5-38.5-38.5-38.5,17.24-38.5,38.5,17.24,38.5,38.5,38.5Zm0-65.72c14.92,0,27.01,12.09,27.01,27.01s-12.09,27.01-27.01,27.01-27.01-12.09-27.01-27.01,12.09-27.01,27.01-27.01Z"/>
|
||||
<circle class="cls-1" cx="78.57" cy="284.18" r="16.48" transform="translate(-19.95 6.28) rotate(-4.06)"/>
|
||||
<path class="cls-1" d="M153.62,418.95c84.62,0,153.19-68.95,153.19-153.57,.28-16.19-2.24-35.53-7.28-53.71,0,0,0,0,0,0,0,0,0-.02,0-.02-2.61-9.42-5.9-18.53-9.82-26.73-16.4-38.76-43.83-83.92-71.53-115.33,4.09,160.17-66.59,58.67-48.5-69.58-59.22,49.02-91.1,134.42-91.1,164.47-9.62-12.19-17.37-32.1-10.81-55.35C19.61,148.65-6.72,227.18,1.48,283.63c8.87,76.18,73.58,135.32,152.14,135.32Zm49.49-216.24c13.57-4.9,27-2.15,30,6.16,3,8.31-6.9,9.93-20.47,14.83-13.57,4.9-25.66,11.23-28.66,2.93-3-8.3,5.56-19.01,19.13-23.92Zm24.9,32.22c17.3,0,32.51,8.93,41.3,22.43,3.49-2.32,7.98-5.66,13.79-10.42-.64,17.02-2.93,28.26-5.88,35.64,.02,.53,.04,1.06,.04,1.6,0,27.2-22.05,49.25-49.25,49.25s-49.25-22.05-49.25-49.25,22.05-49.25,49.25-49.25Zm-71.52,119.09c4.91-.54,10.07-1.22,15.61-2.08l15.43,13.19,10.39-17.94c6.17-1.28,12.8-2.75,19.99-4.43-3.47,34.68-25.65,53.03-56.47,56.41-30.82,3.38-62.2-12.83-74.16-39.77,23.61-1.03,53.06-3.61,69.21-5.38ZM73.5,209.05c3-8.3,16.43-11.06,30-6.16,13.57,4.9,22.13,15.61,19.13,23.92-3,8.3-15.09,1.97-28.66-2.93s-23.47-6.53-20.47-14.83Zm-36.22,48.3c8.78-13.49,23.99-22.42,41.29-22.42,27.2,0,49.25,22.05,49.25,49.25s-22.05,49.25-49.25,49.25-49.25-22.05-49.25-49.25c0-.56,.02-1.11,.04-1.66-2.94-7.37-5.22-18.6-5.86-35.57,5.8,4.75,10.29,8.08,13.77,10.4Z"/>
|
||||
<path class="cls-1" d="M115.73,277.18c0-18.87-15.29-34.16-34.16-34.16s-34.16,15.29-34.16,34.16,15.29,34.16,34.16,34.16,34.16-15.29,34.16-34.16Zm-54.93-.19c0-11.47,9.3-20.77,20.77-20.77s20.77,9.3,20.77,20.77-9.3,20.77-20.77,20.77-20.77-9.3-20.77-20.77Z"/>
|
||||
<path class="cls-1" d="M225.02,243.02c-18.86,0-34.16,15.29-34.16,34.16s15.29,34.16,34.16,34.16,34.16-15.29,34.16-34.16-15.29-34.16-34.16-34.16Zm0,54.74c-11.47,0-20.77-9.3-20.77-20.77s9.3-20.77,20.77-20.77,20.77,9.3,20.77,20.77-9.3,20.77-20.77,20.77Z"/>
|
||||
<path class="cls-1" d="M299.53,211.67s0,0,0,0c0,0,0-.02,0-.02-2.61-9.42-5.9-18.53-9.82-26.73-16.4-38.76-43.83-83.92-71.53-115.33C222.26,229.75,151.58,128.25,169.67,0c-59.22,49.02-91.1,134.42-91.1,164.47-9.62-12.19-17.37-32.1-10.81-55.35C19.61,148.65-6.72,227.18,1.48,283.63c8.87,76.18,73.58,135.33,152.14,135.33,84.62,0,153.19-68.95,153.19-153.57,.28-16.19-2.24-35.53-7.28-53.71Zm-96.3-17.98c12.94-3.65,25.22,4.55,26.37,8.64s-8.17,11.2-21.11,14.85-24.09,4.29-25.76-1.62,7.56-18.22,20.5-21.88Zm-127.61,8.64c1.15-4.09,13.43-12.29,26.37-8.64,12.94,3.66,22.17,15.97,20.5,21.88-1.67,5.91-12.82,5.27-25.76,1.62s-22.26-10.77-21.11-14.85Zm-43.29,74.85c0-.56,.02-1.11,.04-1.66-2.94-7.37-5.22-18.6-5.86-35.57,5.8,4.75,10.29,8.08,13.77,10.4,8.78-13.49,23.99-22.42,41.29-22.42,27.2,0,49.25,22.05,49.25,49.25s-22.05,49.25-49.25,49.25-49.25-22.05-49.25-49.25Zm130.22,125.42c-34.61,3.8-69.85-14.41-83.27-44.65,26.51-1.15,59.58-4.05,77.71-6.04,5.51-.6,11.3-1.37,17.53-2.34l17.33,14.81,11.66-20.15c6.93-1.44,14.38-3.09,22.45-4.98-3.9,38.94-28.8,59.55-63.41,63.34Zm111.73-125.42c0,27.2-22.05,49.25-49.25,49.25s-49.25-22.05-49.25-49.25,22.05-49.25,49.25-49.25c17.3,0,32.51,8.93,41.3,22.43,3.49-2.32,7.98-5.66,13.79-10.42-.64,17.02-2.93,28.26-5.88,35.64,.02,.53,.04,1.06,.04,1.6Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
BIN
src/assets/img/DEGM Thumb.png
Normal file
After Width: | Height: | Size: 314 KiB |
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
BIN
src/assets/img/Logo with circle.png
Normal file
After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 284 KiB |
@ -3,14 +3,12 @@ import navStyles from '../styles/nav.module.scss'
|
||||
export const Banner = () => {
|
||||
return (
|
||||
<div className={navStyles.FundingCampaign}>
|
||||
<a
|
||||
<p
|
||||
className={navStyles.FundingCampaignLink}
|
||||
href='https://geyser.fund/project/degmods'
|
||||
target='_blank'
|
||||
>
|
||||
DEG Mods is running a crowd funding campaign. Chip-in or share the link
|
||||
to help bring the project to life (click me).
|
||||
</a>
|
||||
DEG Mods is currently in alpha (<a href="https://geyser.fund/project/degmods/posts/view/3411" target="_blank">Learn more</a>).
|
||||
Check out its funding campaign (<a href="https://geyser.fund/project/degmods" target="_blank">Learn more</a>). (Soon on Kickstarter)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,41 +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'
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient( rgba(255, 255, 255, 0) 0%, #232323 100%)'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
48
src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
// Define the state interface for error boundary
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
// Define the props interface (if you want to pass any props)
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
// Update state so the next render will show the fallback UI.
|
||||
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
// Log the error and error info (optional)
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo)
|
||||
// You could also send the error to a logging service here
|
||||
console.error('props', this.props)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any fallback UI here
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops! Something went wrong.</h1>
|
||||
<p>Please check console.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If no error, render children
|
||||
return this.props.children
|
||||
}
|
||||
}
|
@ -1,21 +1,28 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
import '../styles/cardGames.css'
|
||||
import { handleGameImageError } from '../utils'
|
||||
|
||||
type GameCardProps = {
|
||||
backgroundLink: string
|
||||
title: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export const GameCard = ({ backgroundLink }: GameCardProps) => {
|
||||
export const GameCard = ({ title, imageUrl }: GameCardProps) => {
|
||||
const route = getGamePageRoute(title)
|
||||
|
||||
return (
|
||||
<a className='cardGameMainWrapperLink' href='search.html'>
|
||||
<div
|
||||
className='cardGameMain'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='cardGameMainTitle'>
|
||||
<p>This is a game title, the best game title</p>
|
||||
<Link className='cardGameMainWrapperLink' to={route}>
|
||||
<div className='cardGameMainWrapper'>
|
||||
<img
|
||||
src={imageUrl}
|
||||
onError={handleGameImageError}
|
||||
className='cardGameMain'
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div className='cardGameMainTitle'>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -1,38 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactQuill from 'react-quill'
|
||||
import 'react-quill/dist/quill.snow.css'
|
||||
import '../styles/customQuillStyles.css'
|
||||
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'
|
||||
import '../styles/styles.css'
|
||||
|
||||
const editorFormats = [
|
||||
'header',
|
||||
'font',
|
||||
'size',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'blockquote',
|
||||
'list',
|
||||
'bullet',
|
||||
'indent',
|
||||
'link'
|
||||
]
|
||||
|
||||
const editorModules = {
|
||||
toolbar: [
|
||||
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||
[{ size: [] }],
|
||||
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
||||
[
|
||||
{ list: 'ordered' },
|
||||
{ list: 'bullet' },
|
||||
{ indent: '-1' },
|
||||
{ indent: '+1' }
|
||||
],
|
||||
['link']
|
||||
]
|
||||
}
|
||||
import '../styles/tiptap.scss'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string
|
||||
@ -77,13 +49,9 @@ export const InputField = React.memo(
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : type === 'richtext' ? (
|
||||
<ReactQuill
|
||||
className='inputMain'
|
||||
formats={editorFormats}
|
||||
modules={editorModules}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(content) => onChange(name, content)}
|
||||
<RichTextEditor
|
||||
content={value}
|
||||
updateContent={(content) => onChange(name, content)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@ -122,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}
|
||||
@ -138,3 +120,258 @@ export const CheckboxField = React.memo(
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
type RichTextEditorProps = {
|
||||
content: string
|
||||
updateContent: (updatedContent: string) => void
|
||||
}
|
||||
|
||||
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'IBMSMSMBSSPostImg'
|
||||
}
|
||||
})
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
// Update the state when the editor content changes
|
||||
updateContent(editor.getHTML())
|
||||
},
|
||||
content
|
||||
})
|
||||
|
||||
// Update editor content when the `content` prop changes
|
||||
useEffect(() => {
|
||||
if (editor && editor.getHTML() !== content) {
|
||||
editor.commands.setContent(content, false)
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
return (
|
||||
<div className='inputMain'>
|
||||
{editor && (
|
||||
<>
|
||||
<MenuBar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MenuBarProps = {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
export const MenuBar = ({ editor }: MenuBarProps) => {
|
||||
const setLink = () => {
|
||||
// Prompt the user to enter a URL
|
||||
let url = prompt('URL')
|
||||
|
||||
// Check if the user provided a URL
|
||||
if (url) {
|
||||
// If the URL doesn't start with 'http://' or 'https://',
|
||||
// prepend 'https://' to the URL
|
||||
if (!/^(http|https):\/\//i.test(url)) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
|
||||
return editor.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
|
||||
// If no URL was provided (e.g., the user cancels the prompt),
|
||||
// return false, indicating that the link was not set.
|
||||
return false
|
||||
}
|
||||
|
||||
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',
|
||||
disabled: !editor.can().chain().focus().toggleBold().run(),
|
||||
isActive: editor.isActive('bold'),
|
||||
onClick: () => editor.chain().focus().toggleBold().run()
|
||||
},
|
||||
{
|
||||
label: 'Italic',
|
||||
disabled: !editor.can().chain().focus().toggleItalic().run(),
|
||||
isActive: editor.isActive('italic'),
|
||||
onClick: () => editor.chain().focus().toggleItalic().run()
|
||||
},
|
||||
{
|
||||
label: 'Strike',
|
||||
disabled: !editor.can().chain().focus().toggleStrike().run(),
|
||||
isActive: editor.isActive('strike'),
|
||||
onClick: () => editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
{
|
||||
label: 'Clear marks',
|
||||
onClick: () => editor.chain().focus().unsetAllMarks().run()
|
||||
},
|
||||
{
|
||||
label: 'Clear nodes',
|
||||
onClick: () => editor.chain().focus().clearNodes().run()
|
||||
},
|
||||
{
|
||||
label: 'Paragraph',
|
||||
isActive: editor.isActive('paragraph'),
|
||||
onClick: () => editor.chain().focus().setParagraph().run()
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
|
||||
label: `H${level}`,
|
||||
isActive: editor.isActive('heading', { level }),
|
||||
onClick: () => editor.chain().focus().toggleHeading({ level }).run()
|
||||
})),
|
||||
{
|
||||
label: 'Bullet list',
|
||||
isActive: editor.isActive('bulletList'),
|
||||
onClick: () => editor.chain().focus().toggleBulletList().run()
|
||||
},
|
||||
{
|
||||
label: 'Ordered list',
|
||||
isActive: editor.isActive('orderedList'),
|
||||
onClick: () => editor.chain().focus().toggleOrderedList().run()
|
||||
},
|
||||
{
|
||||
label: 'Code block',
|
||||
isActive: editor.isActive('codeBlock'),
|
||||
onClick: () => editor.chain().focus().toggleCodeBlock().run()
|
||||
},
|
||||
{
|
||||
label: 'Blockquote',
|
||||
isActive: editor.isActive('blockquote'),
|
||||
onClick: () => editor.chain().focus().toggleBlockquote().run()
|
||||
},
|
||||
{
|
||||
label: 'Link',
|
||||
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()
|
||||
},
|
||||
{
|
||||
label: 'Hard break',
|
||||
onClick: () => editor.chain().focus().setHardBreak().run()
|
||||
},
|
||||
{
|
||||
label: 'Undo',
|
||||
disabled: !editor.can().chain().focus().undo().run(),
|
||||
onClick: () => editor.chain().focus().undo().run()
|
||||
},
|
||||
{
|
||||
label: 'Redo',
|
||||
disabled: !editor.can().chain().focus().redo().run(),
|
||||
onClick: () => editor.chain().focus().redo().run()
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='control-group'>
|
||||
<div className='button-group'>
|
||||
{buttons.map(({ label, disabled, isActive, onClick }) => (
|
||||
<MenuBarButton
|
||||
key={label}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MenuBarButtonProps {
|
||||
label: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => boolean
|
||||
}
|
||||
|
||||
const MenuBarButton = ({
|
||||
label,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: MenuBarButtonProps) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
/**
|
||||
* Uncontrolled input component with design classes, label, description and error support
|
||||
*
|
||||
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
||||
* @param label
|
||||
* @param description
|
||||
* @param error
|
||||
*
|
||||
* @see {@link React.ComponentProps<'input'>}
|
||||
*/
|
||||
export const InputFieldUncontrolled = ({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
...rest
|
||||
}: InputFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
<input className='inputMain' {...rest} />
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CheckboxFieldUncontrolled = ({
|
||||
label,
|
||||
...rest
|
||||
}: CheckboxFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||
</div>
|
||||
)
|
||||
|
42
src/components/Internal/Interactions.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
import { Zap } from './Zap'
|
||||
import { Reactions } from './Reactions'
|
||||
|
||||
type InteractionsProps = {
|
||||
addressable: Addressable
|
||||
commentCount: number
|
||||
}
|
||||
|
||||
export const Interactions = ({
|
||||
addressable,
|
||||
commentCount
|
||||
}: InteractionsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<div className='IBMSMSMBSS_Details'>
|
||||
<a style={{ textDecoration: 'unset', color: 'unset' }}>
|
||||
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{abbreviateNumber(commentCount)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<Zap addressable={addressable} />
|
||||
<Reactions addressable={addressable} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
86
src/components/Internal/PublishDetails.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
77
src/components/Internal/Reactions.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { useReactions } from 'hooks'
|
||||
import { Addressable } from 'types'
|
||||
|
||||
type ReactionsProps = {
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Reactions = ({ addressable }: ReactionsProps) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: addressable.author,
|
||||
eTag: addressable.id,
|
||||
aTag: addressable.aTag
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<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='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'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<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='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'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
83
src/components/Internal/Zap.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { ZapSplit } from 'components/Zap'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
|
||||
type ZapProps = {
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Zap = ({ addressable }: ZapProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
getTotalZapAmount(
|
||||
addressable.author,
|
||||
addressable.id,
|
||||
addressable.aTag,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id='reactBolt'
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
|
||||
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
|
||||
}`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{abbreviateNumber(totalZappedAmount)}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapSplit
|
||||
pubkey={addressable.author}
|
||||
eventId={addressable.id}
|
||||
aTag={addressable.aTag}
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,30 +1,69 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import '../styles/cardMod.css'
|
||||
import { handleModImageError } from '../utils'
|
||||
import { ModDetails } from 'types'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { kinds, nip19 } from 'nostr-tools'
|
||||
import { useDidMount, useNDKContext, useReactions } from 'hooks'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
|
||||
type ModCardProps = {
|
||||
title: string
|
||||
summary: string
|
||||
backgroundLink: string
|
||||
handleClick: () => void
|
||||
}
|
||||
export const ModCard = React.memo((props: ModDetails) => {
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [commentCount, setCommentCount] = useState(0)
|
||||
const { commentEvents } = useComments(props.author, props.aTag)
|
||||
const { likesCount, disLikesCount } = useReactions({
|
||||
pubkey: props.author,
|
||||
eTag: props.id,
|
||||
aTag: props.aTag
|
||||
})
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
getTotalZapAmount(props.author, props.id, props.aTag)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCommentCount(commentEvents.length)
|
||||
}, [commentEvents])
|
||||
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: props.aTag,
|
||||
pubkey: props.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
|
||||
export const ModCard = ({
|
||||
title,
|
||||
summary,
|
||||
backgroundLink,
|
||||
handleClick
|
||||
}: ModCardProps) => {
|
||||
return (
|
||||
<a className='cardModMainWrapperLink' onClick={handleClick}>
|
||||
<Link className='cardModMainWrapperLink' to={route}>
|
||||
<div className='cardModMain'>
|
||||
<div
|
||||
className='cMMPicture'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='cMMPictureWrapper'>
|
||||
<img
|
||||
src={props.featuredImageUrl}
|
||||
onError={handleModImageError}
|
||||
className='cMMPicture'
|
||||
alt={`featured image for mod ${props.title}`}
|
||||
/>
|
||||
{props.nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='cMMBody'>
|
||||
<h3 className='cMMBodyTitle'>{title}</h3>
|
||||
<p className='cMMBodyText'>{summary}</p>
|
||||
<h3 className='cMMBodyTitle'>{props.title}</h3>
|
||||
<p className='cMMBodyText'>{props.summary}</p>
|
||||
<div className='cMMBodyGame'>
|
||||
<p>{props.game}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='cMMFoot'>
|
||||
<div className='cMMFootReactions'>
|
||||
@ -38,7 +77,7 @@ export const ModCard = ({
|
||||
>
|
||||
<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>420</p>
|
||||
<p>{likesCount}</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
@ -50,7 +89,7 @@ export const ModCard = ({
|
||||
>
|
||||
<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>420</p>
|
||||
<p>{disLikesCount}</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
@ -62,11 +101,23 @@ export const ModCard = ({
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 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.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
<p>{commentCount}</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
<p>{totalZappedAmount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
@ -1,6 +1,5 @@
|
||||
import _ from 'lodash'
|
||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import Papa from 'papaparse'
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@ -9,11 +8,15 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
import { useAppSelector, useGames, useNDKContext } from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import '../styles/styles.css'
|
||||
import { DownloadUrl, ModDetails, ModFormState } from '../types'
|
||||
import {
|
||||
initializeFormState,
|
||||
isReachable,
|
||||
@ -24,12 +27,8 @@ import {
|
||||
now
|
||||
} from '../utils'
|
||||
import { CheckboxField, InputError, InputField } from './Inputs'
|
||||
import { RelayController } from '../controllers'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getModsInnerPageRoute } from '../routes'
|
||||
import { DownloadUrl, ModFormState, ModDetails } from '../types'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
|
||||
interface FormErrors {
|
||||
game?: string
|
||||
@ -48,51 +47,43 @@ interface GameOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
let processedCSV = false
|
||||
|
||||
type ModFormProps = {
|
||||
existingModData?: ModDetails
|
||||
}
|
||||
|
||||
export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { ndk, publish } = useNDKContext()
|
||||
const games = useGames()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false)
|
||||
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
|
||||
const [formState, setFormState] = useState<ModFormState>(
|
||||
initializeFormState(existingModData)
|
||||
initializeFormState()
|
||||
)
|
||||
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (processedCSV) return
|
||||
processedCSV = true
|
||||
if (location.pathname === appRoutes.submitMod) {
|
||||
setFormState(initializeFormState())
|
||||
}
|
||||
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
|
||||
|
||||
// Fetch the CSV file from the public folder
|
||||
fetch('/assets/games.csv')
|
||||
.then((response) => response.text())
|
||||
.then((csvText) => {
|
||||
// Parse the CSV text using PapaParse
|
||||
Papa.parse<{
|
||||
'Game Name': string
|
||||
'16 by 9 image': string
|
||||
'Boxart image': string
|
||||
}>(csvText, {
|
||||
worker: true,
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const options = results.data.map((row) => ({
|
||||
label: row['Game Name'],
|
||||
value: row['Game Name']
|
||||
}))
|
||||
setGameOptions(options)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error('Error fetching CSV file:', error))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (existingModData) {
|
||||
setFormState(initializeFormState(existingModData))
|
||||
}
|
||||
}, [existingModData])
|
||||
|
||||
useEffect(() => {
|
||||
const options = games.map((game) => ({
|
||||
label: game['Game Name'],
|
||||
value: game['Game Name']
|
||||
}))
|
||||
setGameOptions(options)
|
||||
}, [games])
|
||||
|
||||
const handleInputChange = useCallback((name: string, value: string) => {
|
||||
setFormState((prevState) => ({
|
||||
@ -185,7 +176,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
|
||||
let hexPubkey: string
|
||||
|
||||
if (userState.isAuth && userState.user?.pubkey) {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
@ -201,20 +192,20 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const uuid = formState.dTag || uuidv4()
|
||||
const currentTimeStamp = now()
|
||||
|
||||
const aTag =
|
||||
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: kinds.ClassifiedListing,
|
||||
created_at: currentTimeStamp,
|
||||
pubkey: hexPubkey,
|
||||
content: formState.body,
|
||||
tags: [
|
||||
['d', formState.dTag || uuid],
|
||||
[
|
||||
'a',
|
||||
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
||||
],
|
||||
['d', uuid],
|
||||
['a', aTag],
|
||||
['r', formState.rTag],
|
||||
['t', T_TAG_VALUE],
|
||||
[
|
||||
@ -253,9 +244,8 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -267,14 +257,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
)}`
|
||||
)
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
id: signedEvent.id,
|
||||
author: signedEvent.pubkey,
|
||||
const naddr = nip19.naddrEncode({
|
||||
identifier: aTag,
|
||||
pubkey: signedEvent.pubkey,
|
||||
kind: signedEvent.kind,
|
||||
relays: publishedOnRelays
|
||||
})
|
||||
|
||||
navigate(getModsInnerPageRoute(nevent))
|
||||
navigate(getModPageRoute(naddr))
|
||||
}
|
||||
|
||||
setIsPublishing(false)
|
||||
@ -337,10 +327,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
} else {
|
||||
for (let i = 0; i < formState.downloadUrls.length; i++) {
|
||||
const downloadUrl = formState.downloadUrls[i]
|
||||
if (
|
||||
!isValidUrl(downloadUrl.url) ||
|
||||
!(await isReachable(downloadUrl.url))
|
||||
) {
|
||||
if (!isValidUrl(downloadUrl.url)) {
|
||||
if (!errors.downloadUrls)
|
||||
errors.downloadUrls = Array(formState.downloadUrls.length)
|
||||
|
||||
@ -408,6 +395,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
name='nsfw'
|
||||
isChecked={formState.nsfw}
|
||||
handleChange={handleCheckboxChange}
|
||||
type='stylized'
|
||||
/>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
@ -432,7 +420,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
We recommend to upload images to https://nostr.build/
|
||||
</p>
|
||||
{formState.screenshotsUrls.map((url, index) => (
|
||||
<Fragment key={`screenShot-${url}`}>
|
||||
<Fragment key={`screenShot-${index}`}>
|
||||
<ScreenshotUrlFields
|
||||
index={index}
|
||||
url={url}
|
||||
@ -481,12 +469,12 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
</div>
|
||||
<p className='labelDescriptionMain'>
|
||||
You can upload your game mod to Github, as an example, and keep
|
||||
updating it there. Also, it's advisable that you hash your package as
|
||||
well with your nostr public key.
|
||||
updating it there (another option is catbox.moe). Also, it's advisable
|
||||
that you hash your package as well with your nostr public key.
|
||||
</p>
|
||||
|
||||
{formState.downloadUrls.map((download, index) => (
|
||||
<Fragment key={`download-${download.url}`}>
|
||||
<Fragment key={`download-${index}`}>
|
||||
<DownloadUrlFields
|
||||
index={index}
|
||||
url={download.url}
|
||||
@ -776,11 +764,12 @@ const GameDropdown = ({
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Game</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
Can't find the game you're looking for? Send us a DM mentioning it so we
|
||||
can add it.
|
||||
Can't find the game you're looking for? You can temporarily publish the
|
||||
mod under '(Unlisted Game)' and later edit it with the proper game name
|
||||
once we add it.
|
||||
</p>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<div className='inputWrapperMain'>
|
||||
<div className='inputWrapperMain inputWrapperMainAlt'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
@ -812,7 +801,7 @@ const GameDropdown = ({
|
||||
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
|
||||
<List
|
||||
height={500}
|
||||
width={'100%'}
|
||||
@ -840,6 +829,11 @@ const GameDropdown = ({
|
||||
</div>
|
||||
</div>
|
||||
{error && <InputError message={error} />}
|
||||
<p className='labelDescriptionMain'>
|
||||
Note: Please mention the game name in the body text of your mod post
|
||||
(e.g., 'This is a mod for Game Name') so we know what to look for and
|
||||
add.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
235
src/components/ModsFilter.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy,
|
||||
WOTFilterOptions
|
||||
} from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ author, filterKey = 'filter' }: Props) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
{/* sort filter options */}
|
||||
<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>
|
||||
|
||||
{/* moderation filter options */}
|
||||
<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
|
||||
|
||||
const isOwnProfile =
|
||||
author &&
|
||||
userState.auth &&
|
||||
userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* wot filter options */}
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Trust: {filterOptions.wot}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(WOTFilterOptions).map((item, index) => {
|
||||
// when user is not logged in
|
||||
if (
|
||||
item === WOTFilterOptions.Site_And_Mine &&
|
||||
!userState.auth
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// when logged in user not admin
|
||||
if (
|
||||
item === WOTFilterOptions.None ||
|
||||
item === WOTFilterOptions.Mine_Only ||
|
||||
item === WOTFilterOptions.Exclude
|
||||
) {
|
||||
const isWoTNpub =
|
||||
userState.user?.npub ===
|
||||
import.meta.env.VITE_SITE_WOT_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author &&
|
||||
userState.auth &&
|
||||
userState.user?.pubkey === author
|
||||
|
||||
if (!(isWoTNpub || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`wotFilterOption-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
wot: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<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>
|
||||
|
||||
{/* source filter options */}
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</div>
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
138
src/components/Pagination.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React from 'react'
|
||||
|
||||
type PaginationProps = {
|
||||
page: number
|
||||
disabledNext: boolean
|
||||
handlePrev: () => void
|
||||
handleNext: () => void
|
||||
}
|
||||
|
||||
export const Pagination = React.memo(
|
||||
({ page, disabledNext, handlePrev, handleNext }: PaginationProps) => {
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handlePrev}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</button>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
<button className='PaginationMainInsideBox PMIBActive'>
|
||||
<p>{page}</p>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handleNext}
|
||||
disabled={disabledNext}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type PaginationWithPageNumbersProps = {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
handlePageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export const PaginationWithPageNumbers = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
handlePageChange
|
||||
}: PaginationWithPageNumbersProps) => {
|
||||
// Function to render the pagination controls with page numbers
|
||||
const renderPagination = () => {
|
||||
const pagesToShow = 5 // Number of page numbers to show around the current page
|
||||
const pageNumbers: (number | string)[] = [] // Array to store page numbers and ellipses
|
||||
|
||||
// Case when the total number of pages is less than or equal to the limit
|
||||
if (totalPages <= pagesToShow + 2) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i) // Add all pages to the pagination
|
||||
}
|
||||
} else {
|
||||
// Add the first page (always visible)
|
||||
pageNumbers.push(1)
|
||||
|
||||
// Calculate the range of pages to show around the current page
|
||||
const startPage = Math.max(2, currentPage - Math.floor(pagesToShow / 2))
|
||||
const endPage = Math.min(
|
||||
totalPages - 1,
|
||||
currentPage + Math.floor(pagesToShow / 2)
|
||||
)
|
||||
|
||||
// Add ellipsis if there are pages between the first page and the startPage
|
||||
if (startPage > 2) pageNumbers.push('...')
|
||||
|
||||
// Add the pages around the current page
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageNumbers.push(i)
|
||||
}
|
||||
|
||||
// Add ellipsis if there are pages between the endPage and the last page
|
||||
if (endPage < totalPages - 1) pageNumbers.push('...')
|
||||
|
||||
// Add the last page (always visible)
|
||||
pageNumbers.push(totalPages)
|
||||
}
|
||||
|
||||
// Map over the array and render each page number or ellipsis
|
||||
return pageNumbers.map((page, index) => {
|
||||
if (typeof page === 'number') {
|
||||
// For actual page numbers, render clickable boxes
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`PaginationMainInsideBox ${
|
||||
currentPage === page ? 'PMIBActive' : '' // Highlight the current page
|
||||
}`}
|
||||
onClick={() => handlePageChange(page)} // Navigate to the selected page
|
||||
>
|
||||
<p>{page}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// For ellipses, render non-clickable dots
|
||||
return (
|
||||
<p key={index} className='PaginationMainInsideBox PMIBDots'>
|
||||
...
|
||||
</p>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<div
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</div>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
<div
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,172 +1,214 @@
|
||||
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
|
||||
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
import { appRoutes, getProfilePageRoute } from '../routes'
|
||||
import '../styles/author.css'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/socialPosts.css'
|
||||
import { UserRelaysType } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
hexToNpub,
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex
|
||||
} from '../utils'
|
||||
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'
|
||||
|
||||
export const ProfileSection = () => {
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const ProfileSection = ({ pubkey }: Props) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainSmallSide'>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_Author'>
|
||||
<div className='IBMSMSMSSS_Author_Top'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left'>
|
||||
<div className='IBMSMSplitMainSmallSideSecWrapper'>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<Profile pubkey={pubkey} />
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
{posts.map((post, index) => (
|
||||
<a
|
||||
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
|
||||
href='profile.html'
|
||||
key={'post' + index}
|
||||
className='IBMSMSMSSS_ShortPostsPostLink'
|
||||
href={post.link}
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
|
||||
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background:
|
||||
"url('/assets/img/media-cache%20(4).png') center / cover no-repeat"
|
||||
}}
|
||||
></div>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost'>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
|
||||
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
|
||||
{post.name}
|
||||
</p>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>
|
||||
{author.name}
|
||||
</p>
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>
|
||||
{author.handle}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
|
||||
<p>{post.content}</p>
|
||||
{post.imageUrl && (
|
||||
<div
|
||||
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
|
||||
style={{
|
||||
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p
|
||||
id='SiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_Address'
|
||||
>
|
||||
{author.address}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'>
|
||||
<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='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<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='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className='btn btnMain' type='button'>
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
{posts.map((post, index) => (
|
||||
<a
|
||||
key={'post' + index}
|
||||
className='IBMSMSMSSS_ShortPostsPostLink'
|
||||
href={post.link}
|
||||
>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost'>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
|
||||
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
|
||||
{post.name}
|
||||
</p>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
|
||||
<p>{post.content}</p>
|
||||
{post.imageUrl && (
|
||||
<div
|
||||
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
|
||||
style={{
|
||||
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Author {
|
||||
name: string
|
||||
handle: string
|
||||
address: string
|
||||
bio: string
|
||||
type ProfileProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const author: Author = {
|
||||
name: 'Freakoverse',
|
||||
handle: 'freakoverse@degmods.com',
|
||||
address: 'npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r',
|
||||
bio: `I guess I'm one of those #vtubers . Having fun talking about general topics, vrchat/similar, and games. Also #indiedev #gamedev You can call me: Freak فْرِيكٌ <20><><EFBFBD>リク (still learning Nihongo). #envtuber #podcast #gaming #gamedev`
|
||||
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 () => {
|
||||
copyTextToClipboard(npub).then((isCopied) => {
|
||||
if (isCopied) {
|
||||
toast.success('Npub copied to clipboard!')
|
||||
} else {
|
||||
toast.error(
|
||||
'Failed to copy, look into console for more details on error!'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Try to encode
|
||||
let profileRoute = appRoutes.home
|
||||
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 (
|
||||
<div className='IBMSMSMSSS_Author'>
|
||||
<div className='IBMSMSMSSS_Author_Top'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left'>
|
||||
<Link
|
||||
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
|
||||
to={profileRoute}
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
|
||||
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background: `url('${image}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
|
||||
{typeof nip05 === 'string' && (
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p
|
||||
id='SiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_Address'
|
||||
>
|
||||
{npub}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
{typeof lud16 !== 'undefined' && (
|
||||
<ZapButtonWithPopUp pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{about}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Post {
|
||||
@ -178,47 +220,330 @@ interface Post {
|
||||
|
||||
const posts: Post[] = [
|
||||
{
|
||||
name: 'Freakoverse',
|
||||
name: 'User name',
|
||||
link: `feed-note.html`,
|
||||
content: ` So I know HTML/CSS pretty well and I'm confident with
|
||||
them.\n\n\n\nI also know UI and UX, as
|
||||
well as graphic design (nowhere near pros, but I'm the
|
||||
guy they call when the pro isn't around or when
|
||||
something is needed quickly).\n\n\n\nI
|
||||
don't know much java. Usually, I'd search for what I
|
||||
want, find something close, and fiddle with it until
|
||||
it works/gets the desired result ish. AI is helping
|
||||
with this a lot actually.\n\n\n\nThis
|
||||
helped me create my own sites and my own designs to
|
||||
life, though just at a static level. I always wanted
|
||||
to make dynamic sites, but the idea of doing backend
|
||||
stuff is complex to me. However...\n\n\n\n"Let
|
||||
me look into it again" and thought if I could make a
|
||||
simple blog. Digging a bit, and watching/skimming
|
||||
through tutorials, I realized that I think I can.\n\n\n\nNot
|
||||
sure when I'll start/attempt this, but will journey
|
||||
into learning the basics of PHP and attempting to make
|
||||
a blog. I guess I'll learn the basics of PHP, and then
|
||||
head into Laravel. If I manage to get the hang of it,
|
||||
I'll attempt to make a complex old project I had, and
|
||||
if I do manage to do it, I'll be pretty confident
|
||||
=3\n\n\n\nAside from that, would be
|
||||
nice to make a website, a personal blog, that shows my
|
||||
long-form articles only. Hopefully by then things
|
||||
would be more stable nostr-wise, cleaner, and easier
|
||||
in terms of learning, so I'd be able to do it (or
|
||||
collab with someone to do it / to make a template for
|
||||
all to have and deploy easily).\n\n\n\n`
|
||||
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
|
||||
},
|
||||
{
|
||||
name: 'Freakoverse',
|
||||
link: 'https://primal.net/e/note1j7zmj4g6grc39zq30xq2de95dfszjpwlqvsktv65h7kuzjzsytjqgx73c7',
|
||||
content: `Happy to see some gamedevs port their games from Unity to Godot, after that Unity fiasco, like this one here called Road To Vostok`
|
||||
name: 'User name',
|
||||
link: 'feed-note.html',
|
||||
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
|
||||
},
|
||||
{
|
||||
name: 'Freakoverse',
|
||||
name: 'User name',
|
||||
link: `feed-note.html`,
|
||||
content: `This is good.`,
|
||||
imageUrl: '/assets/img/media-cache%20(1).jpg'
|
||||
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`,
|
||||
imageUrl: placeholder
|
||||
}
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
nprofile: string
|
||||
}
|
||||
|
||||
export const ProfileQRButtonWithPopUp = ({
|
||||
nprofile
|
||||
}: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.target = '_blank' // Open in a new tab
|
||||
a.rel = 'noopener noreferrer' // Recommended for security reasons
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<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='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Nostr Address</h3>
|
||||
</div>
|
||||
<div
|
||||
className='popUpMainCardTopClose'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<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='popUpMainCardBottom'>
|
||||
<QRCodeSVG
|
||||
className='popUpMainCardBottomQR'
|
||||
onClick={onQrCodeClicked}
|
||||
value={nprofile}
|
||||
height={235}
|
||||
width={235}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<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='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={pubkey}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type FollowButtonProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(async () => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
const userHexKey = userState.user.pubkey as string
|
||||
const { isFollowing: isAlreadyFollowing } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
setIsFollowing(isAlreadyFollowing)
|
||||
}
|
||||
})
|
||||
|
||||
const getUserPubKey = async (): Promise<string | null> => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
return userState.user.pubkey as string
|
||||
} else {
|
||||
return (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfFollowing = async (
|
||||
userHexKey: string,
|
||||
pubkey: string
|
||||
): Promise<{
|
||||
isFollowing: boolean
|
||||
tags: string[][]
|
||||
}> => {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent = await fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (!contactListEvent)
|
||||
return {
|
||||
isFollowing: false,
|
||||
tags: []
|
||||
}
|
||||
|
||||
return {
|
||||
isFollowing: contactListEvent.tags.some(
|
||||
(t) => t[0] === 'p' && t[1] === pubkey
|
||||
),
|
||||
tags: contactListEvent.tags
|
||||
}
|
||||
}
|
||||
|
||||
const signAndPublishEvent = async (
|
||||
unsignedEvent: UnsignedEvent
|
||||
): Promise<boolean> => {
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing follow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
if (isAlreadyFollowing) {
|
||||
toast.info('Already following!')
|
||||
setIsFollowing(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: [...tags, ['p', pubkey]]
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing follow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleUnFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing unfollow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent = await fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (
|
||||
!contactListEvent ||
|
||||
!contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey)
|
||||
) {
|
||||
// could not found target pubkey in user's follow list
|
||||
// so, just update the status and return
|
||||
setIsFollowing(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: contactListEvent.tags.filter(
|
||||
(t) => !(t[0] === 'p' && t[1] === pubkey)
|
||||
)
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing unfollow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(!success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={isFollowing ? handleUnFollow : handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Un-Follow' : 'Follow'}
|
||||
</button>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
51
src/components/SVGs.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
export const ProfileSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M224 256c70.7 0 128-57.31 128-128s-57.3-128-128-128C153.3 0 96 57.31 96 128S153.3 256 224 256zM274.7 304H173.3C77.61 304 0 381.6 0 477.3c0 19.14 15.52 34.67 34.66 34.67h378.7C432.5 512 448 496.5 448 477.3C448 381.6 370.4 304 274.7 304z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const RelaySVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M480 288H32c-17.62 0-32 14.38-32 32v128c0 17.62 14.38 32 32 32h448c17.62 0 32-14.38 32-32v-128C512 302.4 497.6 288 480 288zM352 408c-13.25 0-24-10.75-24-24s10.75-24 24-24s24 10.75 24 24S365.3 408 352 408zM416 408c-13.25 0-24-10.75-24-24s10.75-24 24-24s24 10.75 24 24S429.3 408 416 408zM480 32H32C14.38 32 0 46.38 0 64v128c0 17.62 14.38 32 32 32h448c17.62 0 32-14.38 32-32V64C512 46.38 497.6 32 480 32zM352 152c-13.25 0-24-10.75-24-24S338.8 104 352 104S376 114.8 376 128S365.3 152 352 152zM416 152c-13.25 0-24-10.75-24-24S402.8 104 416 104S440 114.8 440 128S429.3 152 416 152z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PreferenceSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M0 416C0 398.3 14.33 384 32 384H86.66C99 355.7 127.2 336 160 336C192.8 336 220.1 355.7 233.3 384H480C497.7 384 512 398.3 512 416C512 433.7 497.7 448 480 448H233.3C220.1 476.3 192.8 496 160 496C127.2 496 99 476.3 86.66 448H32C14.33 448 0 433.7 0 416V416zM192 416C192 398.3 177.7 384 160 384C142.3 384 128 398.3 128 416C128 433.7 142.3 448 160 448C177.7 448 192 433.7 192 416zM352 176C384.8 176 412.1 195.7 425.3 224H480C497.7 224 512 238.3 512 256C512 273.7 497.7 288 480 288H425.3C412.1 316.3 384.8 336 352 336C319.2 336 291 316.3 278.7 288H32C14.33 288 0 273.7 0 256C0 238.3 14.33 224 32 224H278.7C291 195.7 319.2 176 352 176zM384 256C384 238.3 369.7 224 352 224C334.3 224 320 238.3 320 256C320 273.7 334.3 288 352 288C369.7 288 384 273.7 384 256zM480 64C497.7 64 512 78.33 512 96C512 113.7 497.7 128 480 128H265.3C252.1 156.3 224.8 176 192 176C159.2 176 131 156.3 118.7 128H32C14.33 128 0 113.7 0 96C0 78.33 14.33 64 32 64H118.7C131 35.75 159.2 16 192 16C224.8 16 252.1 35.75 265.3 64H480zM160 96C160 113.7 174.3 128 192 128C209.7 128 224 113.7 224 96C224 78.33 209.7 64 192 64C174.3 64 160 78.33 160 96z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AdminSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -32 576 576'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
|
||||
</svg>
|
||||
)
|
39
src/components/SearchInput.tsx
Normal 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>
|
||||
)
|
||||
)
|
9
src/components/Spinner.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import styles from '../styles/dotsSpinner.module.scss'
|
||||
|
||||
export const Spinner = () => (
|
||||
<div className='spinner'>
|
||||
<div className='spinnerCircle'></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Dots = () => <span className={styles.loading}></span>
|
26
src/components/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
918
src/components/Zap.tsx
Normal file
@ -0,0 +1,918 @@
|
||||
import { getRelayListForUser } from '@nostr-dev-kit/ndk'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
import Countdown, { CountdownRenderProps } from 'react-countdown'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ZapController } from '../controllers'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
|
||||
import '../styles/popup.css'
|
||||
import { PaymentRequest, UserProfile } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
formatNumber,
|
||||
getTagValue,
|
||||
getZapAmount,
|
||||
unformatNumber
|
||||
} from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
|
||||
|
||||
type PresetAmountProps = {
|
||||
label: string
|
||||
value: number
|
||||
setAmount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const PresetAmount = React.memo(
|
||||
({ label, value, setAmount }: PresetAmountProps) => {
|
||||
return (
|
||||
<button
|
||||
className='btn btnMain pUMCB_ZapsInsideAmountOptionsBtn'
|
||||
type='button'
|
||||
onClick={() => setAmount(value)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type ZapPresetsProps = {
|
||||
setAmount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const ZapPresets = React.memo(({ setAmount }: ZapPresetsProps) => {
|
||||
return (
|
||||
<>
|
||||
<PresetAmount label='1K' value={1000} setAmount={setAmount} />
|
||||
<PresetAmount label='5K' value={5000} setAmount={setAmount} />
|
||||
<PresetAmount label='10K' value={10000} setAmount={setAmount} />
|
||||
<PresetAmount label='25K' value={25000} setAmount={setAmount} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
type ZapButtonsProps = {
|
||||
disabled: boolean
|
||||
handleGenerateQRCode: () => void
|
||||
handleSend: () => void
|
||||
}
|
||||
|
||||
export const ZapButtons = ({
|
||||
disabled,
|
||||
handleGenerateQRCode,
|
||||
handleSend
|
||||
}: ZapButtonsProps) => {
|
||||
return (
|
||||
<div className='pUMCB_ZapsInsideBtns'>
|
||||
<button
|
||||
className='btn btnMain pUMCB_ZapsInsideElementBtn'
|
||||
type='button'
|
||||
onClick={handleGenerateQRCode}
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain pUMCB_ZapsInsideElementBtn'
|
||||
type='button'
|
||||
onClick={handleSend}
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
|
||||
</svg>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapQRProps = {
|
||||
paymentRequest: PaymentRequest
|
||||
handleClose: () => void
|
||||
handleQRExpiry: () => void
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const ZapQR = React.memo(
|
||||
({
|
||||
paymentRequest,
|
||||
handleClose,
|
||||
handleQRExpiry,
|
||||
setTotalZapAmount,
|
||||
setHasZapped
|
||||
}: ZapQRProps) => {
|
||||
const { ndk } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
ZapController.getInstance()
|
||||
.pollZapReceipt(paymentRequest, ndk)
|
||||
.then((zapReceipt) => {
|
||||
toast.success(`Successfully sent sats!`)
|
||||
if (setTotalZapAmount) {
|
||||
const amount = getZapAmount(zapReceipt)
|
||||
setTotalZapAmount((prev) => prev + amount)
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
.finally(() => {
|
||||
handleClose()
|
||||
})
|
||||
})
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
if (!paymentRequest) return
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
zapController.sendPayment(paymentRequest.pr)
|
||||
} else {
|
||||
console.warn('Webln provider not present')
|
||||
|
||||
const href = `lightning:${paymentRequest.pr}`
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.click()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
|
||||
<QRCodeSVG
|
||||
className='popUpMainCardBottomQR'
|
||||
onClick={onQrCodeClicked}
|
||||
value={paymentRequest.pr}
|
||||
height={235}
|
||||
width={235}
|
||||
/>
|
||||
<label
|
||||
className='popUpMainCardBottomLnurl'
|
||||
onClick={() => {
|
||||
copyTextToClipboard(paymentRequest.pr).then((isCopied) => {
|
||||
if (isCopied) toast.success('Lnurl copied to clipboard!')
|
||||
})
|
||||
}}
|
||||
>
|
||||
{paymentRequest.pr}
|
||||
</label>
|
||||
<Timer onTimerExpired={handleQRExpiry} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const MAX_POLLING_TIME = 2 * 60 * 1000 // 2 minutes in milliseconds
|
||||
|
||||
const renderer = ({ minutes, seconds }: CountdownRenderProps) => (
|
||||
<span>
|
||||
{minutes}:{seconds}
|
||||
</span>
|
||||
)
|
||||
|
||||
type TimerProps = {
|
||||
onTimerExpired: () => void
|
||||
}
|
||||
|
||||
const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
|
||||
const expiryTime = useMemo(() => {
|
||||
return Date.now() + MAX_POLLING_TIME
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<i className='fas fa-clock'></i>
|
||||
<Countdown
|
||||
date={expiryTime}
|
||||
renderer={renderer}
|
||||
onComplete={onTimerExpired}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type ZapPopUpProps = {
|
||||
title: string
|
||||
labelDescriptionMain?: ReactNode
|
||||
receiver: string
|
||||
eventId?: string
|
||||
aTag?: string
|
||||
notCloseAfterZap?: boolean
|
||||
lastNode?: ReactNode
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const ZapPopUp = ({
|
||||
title,
|
||||
labelDescriptionMain,
|
||||
receiver,
|
||||
eventId,
|
||||
aTag,
|
||||
lastNode,
|
||||
notCloseAfterZap,
|
||||
setTotalZapAmount,
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapPopUpProps) => {
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
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) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('finding receiver metadata')
|
||||
|
||||
const receiverMetadata = await findMetadata(receiver)
|
||||
|
||||
if (!receiverMetadata?.lud16) {
|
||||
setIsLoading(false)
|
||||
toast.error('Lighting address (lud16) is missing in receiver metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!receiverMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('pubkey is missing in receiver metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the receiver's read relays.
|
||||
const receiverRelays = await getRelayListForUser(receiver, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList.readRelayUrls
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`An error occurred in getting zap receiver's read relays`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
setLoadingSpinnerDesc('Creating zap request')
|
||||
return await zapController
|
||||
.getLightningPaymentRequest(
|
||||
receiverMetadata.lud16,
|
||||
amount,
|
||||
receiverMetadata.pubkey as string,
|
||||
receiverRelays,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, message, userState, receiver, eventId, aTag, ndk, findMetadata])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
await zapController
|
||||
.sendPayment(pr.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${amount} sats!`)
|
||||
if (setTotalZapAmount) {
|
||||
setTotalZapAmount((prev) => prev + amount)
|
||||
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
|
||||
if (!notCloseAfterZap) {
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [
|
||||
amount,
|
||||
notCloseAfterZap,
|
||||
handleClose,
|
||||
generatePaymentRequest,
|
||||
setTotalZapAmount,
|
||||
setHasZapped
|
||||
])
|
||||
|
||||
const handleQRExpiry = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
}, [])
|
||||
|
||||
const handleQRClose = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
setIsLoading(false)
|
||||
if (!notCloseAfterZap) {
|
||||
handleClose()
|
||||
}
|
||||
}, [notCloseAfterZap, handleClose])
|
||||
|
||||
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>{title}</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' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='pUMCB_ZapsInsideAmount'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
{labelDescriptionMain}
|
||||
<label className='form-label labelMain'>
|
||||
Amount (Satoshis)
|
||||
</label>
|
||||
<input
|
||||
className='inputMain'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Message (optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{paymentRequest && (
|
||||
<ZapQR
|
||||
paymentRequest={paymentRequest}
|
||||
handleClose={handleQRClose}
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)}
|
||||
{lastNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapSplitProps = {
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
aTag?: string
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const ZapSplit = ({
|
||||
pubkey,
|
||||
eventId,
|
||||
aTag,
|
||||
setTotalZapAmount,
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapSplitProps) => {
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [authorPercentage, setAuthorPercentage] = useState(90)
|
||||
const [adminPercentage, setAdminPercentage] = useState(10)
|
||||
|
||||
const [author, setAuthor] = useState<UserProfile>()
|
||||
const [admin, setAdmin] = useState<UserProfile>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [invoices, setInvoices] = useState<Map<string, PaymentRequest>>()
|
||||
|
||||
useDidMount(async () => {
|
||||
findMetadata(pubkey).then((res) => {
|
||||
setAuthor(res)
|
||||
})
|
||||
|
||||
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
findMetadata(adminNpubs[0]).then((res) => {
|
||||
setAdmin(res)
|
||||
})
|
||||
})
|
||||
|
||||
const handleAuthorPercentageChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newValue = parseInt(e.target.value)
|
||||
setAuthorPercentage(newValue)
|
||||
setAdminPercentage(100 - newValue)
|
||||
}
|
||||
|
||||
const handleAdminPercentageChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newValue = parseInt(e.target.value)
|
||||
setAdminPercentage(newValue)
|
||||
setAuthorPercentage(100 - newValue) // Update the other slider to maintain 100%
|
||||
}
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const generatePaymentInvoices = async () => {
|
||||
if (!amount) return null
|
||||
|
||||
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) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
const adminShare = Math.floor((amount * adminPercentage) / 100)
|
||||
const authorShare = amount - adminShare
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
const invoices = new Map<string, PaymentRequest>()
|
||||
|
||||
if (authorShare > 0 && author?.pubkey && author?.lud16) {
|
||||
// Find the receiver's read relays.
|
||||
const authorRelays = await getRelayListForUser(
|
||||
author.pubkey as string,
|
||||
ndk
|
||||
)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList.readRelayUrls
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`An error occurred in getting zap receiver's read relays`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
setLoadingSpinnerDesc('Generating invoice for author')
|
||||
const invoice = await zapController
|
||||
.getLightningPaymentRequest(
|
||||
author.lud16,
|
||||
authorShare,
|
||||
author.pubkey as string,
|
||||
authorRelays,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (invoice) {
|
||||
invoices.set('author', invoice)
|
||||
}
|
||||
}
|
||||
|
||||
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
|
||||
// Find the receiver's read relays.
|
||||
const adminRelays = await getRelayListForUser(admin.pubkey as string, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList.readRelayUrls
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`An error occurred in getting zap receiver's read relays`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
setLoadingSpinnerDesc('Generating invoice for site owner')
|
||||
const invoice = await zapController
|
||||
.getLightningPaymentRequest(
|
||||
admin.lud16,
|
||||
adminShare,
|
||||
admin.pubkey as string,
|
||||
adminRelays,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (invoice) {
|
||||
invoices.set('admin', invoice)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
return invoices
|
||||
}
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const paymentInvoices = await generatePaymentInvoices()
|
||||
|
||||
if (!paymentInvoices) return
|
||||
|
||||
setInvoices(paymentInvoices)
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const paymentInvoices = await generatePaymentInvoices()
|
||||
|
||||
if (!paymentInvoices) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
const authorInvoice = paymentInvoices.get('author')
|
||||
if (authorInvoice) {
|
||||
setLoadingSpinnerDesc('Sending payment to author')
|
||||
|
||||
const sats = parseInt(getTagValue(authorInvoice, 'amount')![0]) / 1000
|
||||
|
||||
await zapController
|
||||
.sendPayment(authorInvoice.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${sats} sats to author!`)
|
||||
if (setTotalZapAmount) {
|
||||
setTotalZapAmount((prev) => prev + sats)
|
||||
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
}
|
||||
|
||||
const adminInvoice = paymentInvoices.get('admin')
|
||||
if (adminInvoice) {
|
||||
setLoadingSpinnerDesc('Sending payment to site owner')
|
||||
|
||||
const sats = parseInt(getTagValue(adminInvoice, 'amount')![0]) / 1000
|
||||
|
||||
await zapController
|
||||
.sendPayment(adminInvoice.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${sats} sats to site owner!`)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
}
|
||||
|
||||
handleClose()
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setInvoices(paymentInvoices)
|
||||
}
|
||||
}
|
||||
|
||||
const removeInvoice = (key: string) => {
|
||||
setInvoices((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(key)
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
|
||||
const displayQR = () => {
|
||||
if (!invoices) return null
|
||||
|
||||
const authorInvoice = invoices.get('author')
|
||||
if (authorInvoice) {
|
||||
return (
|
||||
<ZapQR
|
||||
key={authorInvoice.pr}
|
||||
paymentRequest={authorInvoice}
|
||||
handleClose={() => removeInvoice('author')}
|
||||
handleQRExpiry={() => removeInvoice('author')}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const adminInvoice = invoices.get('admin')
|
||||
if (adminInvoice) {
|
||||
return (
|
||||
<ZapQR
|
||||
key={adminInvoice.pr}
|
||||
paymentRequest={adminInvoice}
|
||||
handleClose={() => {
|
||||
removeInvoice('admin')
|
||||
handleClose()
|
||||
}}
|
||||
handleQRExpiry={() => removeInvoice('admin')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const authorName = author?.displayName || author?.name || '[name not set up]'
|
||||
const adminName = admin?.displayName || admin?.name || '[name not set up]'
|
||||
|
||||
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>Tip/Zap</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' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='pUMCB_ZapsInsideAmount'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Amount (Satoshis)
|
||||
</label>
|
||||
<input
|
||||
className='inputMain'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Message (optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Tip Split</label>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('${
|
||||
author?.image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>
|
||||
{authorName}
|
||||
</p>
|
||||
{author?.nip05 && (
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'>
|
||||
{author.nip05}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ZapSplitUserBoxRange'>
|
||||
<input
|
||||
className='form-range inputRangeMain inputRangeMainZap'
|
||||
type='range'
|
||||
max='100'
|
||||
min='0'
|
||||
value={authorPercentage}
|
||||
onChange={handleAuthorPercentageChange}
|
||||
step='1'
|
||||
required
|
||||
name='ZapSplitName'
|
||||
/>
|
||||
<p className='ZapSplitUserBoxRangeText'>
|
||||
{authorPercentage}%
|
||||
</p>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
This goes to show your appreciation to the mod creator!
|
||||
</p>
|
||||
</div>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('${
|
||||
admin?.image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>
|
||||
{adminName}
|
||||
</p>
|
||||
{admin?.nip05 && (
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ZapSplitUserBoxRange'>
|
||||
<input
|
||||
className='form-range inputRangeMain inputRangeMainZap'
|
||||
type='range'
|
||||
max='100'
|
||||
min='0'
|
||||
value={adminPercentage}
|
||||
onChange={handleAdminPercentageChange}
|
||||
step='1'
|
||||
required
|
||||
name='ZapSplitName'
|
||||
/>
|
||||
<p className='ZapSplitUserBoxRangeText'>
|
||||
{adminPercentage}%
|
||||
</p>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
Help with the development, maintenance, management, and
|
||||
growth of DEG Mods.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{displayQR()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
599
src/components/comment/index.tsx
Normal file
@ -0,0 +1,599 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { Dots, Spinner } from 'components/Spinner'
|
||||
import { ZapPopUp } from 'components/Zap'
|
||||
import { formatDate } from 'date-fns'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext,
|
||||
useReactions
|
||||
} from 'hooks'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { getProfilePageRoute } from 'routes'
|
||||
import {
|
||||
Addressable,
|
||||
CommentEvent,
|
||||
CommentEventStatus,
|
||||
UserProfile
|
||||
} from 'types/index.ts'
|
||||
import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils'
|
||||
|
||||
enum SortByEnum {
|
||||
Latest = 'Latest',
|
||||
Oldest = 'Oldest'
|
||||
}
|
||||
|
||||
enum AuthorFilterEnum {
|
||||
All_Comments = 'All Comments',
|
||||
Creator_Comments = 'Creator Comments'
|
||||
}
|
||||
|
||||
type FilterOptions = {
|
||||
sort: SortByEnum
|
||||
author: AuthorFilterEnum
|
||||
}
|
||||
|
||||
type Props = {
|
||||
addressable: Addressable
|
||||
setCommentCount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Comments = ({ addressable, setCommentCount }: Props) => {
|
||||
const { ndk, publish } = useNDKContext()
|
||||
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])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleSubmit = async (content: string): Promise<boolean> => {
|
||||
if (content === '') return false
|
||||
|
||||
let pubkey: string
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
pubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
pubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
toast.error('Could not get user pubkey')
|
||||
return false
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: content,
|
||||
pubkey: pubkey,
|
||||
kind: kinds.ShortTextNote,
|
||||
created_at: now(),
|
||||
tags: [
|
||||
['e', addressable.id],
|
||||
['a', addressable.aTag],
|
||||
['p', addressable.author]
|
||||
]
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
setCommentEvents((prev) => [
|
||||
{
|
||||
...signedEvent,
|
||||
status: CommentEventStatus.Publishing
|
||||
},
|
||||
...prev
|
||||
])
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
publish(ndkEvent)
|
||||
.then((publishedOnRelays) => {
|
||||
if (publishedOnRelays.length === 0) {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Failed
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Published
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// when an event is successfully published remove the status from it after 15 seconds
|
||||
setTimeout(() => {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
delete event.status
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
}, 15000)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('An error occurred in publishing comment', err)
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Failed
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleDiscoveredClick = () => {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
const [visible, setVisible] = useState<CommentEvent[]>([])
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
}, [commentEvents, isLoading])
|
||||
|
||||
const comments = useMemo(() => {
|
||||
let filteredComments = visible
|
||||
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
|
||||
filteredComments = filteredComments.filter(
|
||||
(comment) => comment.pubkey === addressable.author
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortByEnum.Latest) {
|
||||
filteredComments.sort((a, b) => b.created_at - a.created_at)
|
||||
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||
filteredComments.sort((a, b) => a.created_at - b.created_at)
|
||||
}
|
||||
|
||||
return filteredComments
|
||||
}, [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'>
|
||||
{/* 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}
|
||||
/>
|
||||
<div className='IBMSMSMBSSCommentsList'>
|
||||
{comments.map((event) => (
|
||||
<Comment key={event.id} {...event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CommentFormProps = {
|
||||
handleSubmit: (content: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const CommentForm = ({ handleSubmit }: CommentFormProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
|
||||
const handleComment = async () => {
|
||||
setIsSubmitting(true)
|
||||
const submitted = await handleSubmit(commentText)
|
||||
if (submitted) setCommentText('')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCommentsCreation'>
|
||||
<div className='IBMSMSMBSSCC_Top'>
|
||||
<textarea
|
||||
className='IBMSMSMBSSCC_Top_Box'
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCC_Bottom'>
|
||||
<button
|
||||
className='btnMain'
|
||||
onClick={handleComment}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Comment'}
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FilterProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
const Filter = React.memo(
|
||||
({ filterOptions, setFilterOptions }: FilterProps) => {
|
||||
return (
|
||||
<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(SortByEnum).map((item) => (
|
||||
<div
|
||||
key={`sortBy-${item}`}
|
||||
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.author}
|
||||
</button>
|
||||
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(AuthorFilterEnum).map((item) => (
|
||||
<div
|
||||
key={`sortBy-${item}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
author: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const Comment = (props: CommentEvent) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(() => {
|
||||
findMetadata(props.pubkey).then((res) => {
|
||||
setProfile(res)
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: props.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(props.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(props.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(props.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
{props.status && (
|
||||
<p className='IBMSMSMBSSCL_CBTextStatus'>
|
||||
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
|
||||
{props.status}
|
||||
</p>
|
||||
)}
|
||||
<p className='IBMSMSMBSSCL_CBText'>{props.content}</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...props} />
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<Zap {...props} />
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 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.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
|
||||
</div>
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Reactions = (props: Event) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: props.pubkey,
|
||||
eTag: props.id
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<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'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<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'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Zap = (props: Event) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
getTotalZapAmount(
|
||||
props.pubkey,
|
||||
props.id,
|
||||
undefined,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEBolt ${
|
||||
hasZapped ? 'IBMSMSMBSSCL_CAEBoltActive' : ''
|
||||
}`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{abbreviateNumber(totalZappedAmount)}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={props.pubkey}
|
||||
eventId={props.id}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
124
src/constants.ts
@ -1 +1,125 @@
|
||||
export const T_TAG_VALUE = 'GameMod'
|
||||
export const MOD_FILTER_LIMIT = 20
|
||||
export const LANDING_PAGE_DATA = {
|
||||
featuredSlider: [
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5cek8pnrwc34xgknyv33xqkngc34xyknscfjxsknzvp38quxgc33vejnqvqhqecq8',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vpcxs6nwwp3x5knyd3evckngetxxcknjdfkx5kngdfhvgukvwfjxsunseqnend73',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5dp4xsex2e3cxuknsdryvvkngc3sxcknjef4vcknvvmyvcukyd3kvd3rxdgnuver5',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vf5x9nrxcekxvknjvmzxvkngcfsx5kkzcf3xqknsvmrvgenwe3j8p3nzwgka59vj',
|
||||
'naddr1qvzqqqrkcgpzph2jv2ejvdk27hn36dt57j6f69f5h0zccve3xceujq5z9jk8ym8wqp4nxvp5xqer5eryx5ervvnzxvervvekvdskvdt9xuckgve4xu6xvdrzxsukgvf4xv6xycnrx5uxxvenxvcnxd3nxd3njvpj8qerycmpvvmnydnrv4jn5wrpv5mrvwpsxgknxwp4xqkngetpxqknjd35xcknsv3cv9jnxvtp8ycxyegq5ndhc'
|
||||
],
|
||||
awesomeMods: [
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5df5xccngvtrxqkkydpexukngvp4xgknsvp4vskkgdrxvgmkxdmp8quxycgx78rpf',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vrrvgmnjc33xuknwde4vskngvekxgknsenyxvkk2ctxvscrvenpvsmnxeqydygjx'
|
||||
],
|
||||
featuredGames: [
|
||||
'Persona 3 Reload',
|
||||
"Baldur's Gate 3",
|
||||
'Cyberpunk 2077',
|
||||
'ELDEN RING',
|
||||
'The Coffin of Andy and Leyley'
|
||||
],
|
||||
featuredBlogPosts: [
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
|
||||
]
|
||||
}
|
||||
// we use this object to check if a user has reacted positively or negatively to a post
|
||||
// reactions are kind 7 events and their content is either emoji icon or emoji shortcode
|
||||
// Extend the following object as per need to include more emojis and shortcodes
|
||||
// NOTE: In following object emojis and shortcode array are not interlinked.
|
||||
// Both of these arrays can have separate items
|
||||
export const REACTIONS = {
|
||||
positive: {
|
||||
emojis: [
|
||||
'+',
|
||||
'❤️',
|
||||
'💙',
|
||||
'💖',
|
||||
'💚',
|
||||
'⭐',
|
||||
'🚀',
|
||||
'🫂',
|
||||
'🎉',
|
||||
'🥳',
|
||||
'🎊',
|
||||
'👍',
|
||||
'💪',
|
||||
'😎'
|
||||
],
|
||||
shortCodes: [
|
||||
':red_heart:',
|
||||
':blue_heart:',
|
||||
':sparkling_heart:',
|
||||
':green_heart:',
|
||||
':star:',
|
||||
':rocket:',
|
||||
':people_hugging:',
|
||||
':party_popper:',
|
||||
':tada:',
|
||||
':partying_face:',
|
||||
':confetti_ball:',
|
||||
':thumbs_up:',
|
||||
':+1:',
|
||||
':thumbsup:',
|
||||
':thumbup:',
|
||||
':flexed_biceps:',
|
||||
':muscle:'
|
||||
]
|
||||
},
|
||||
negative: {
|
||||
emojis: [
|
||||
'-',
|
||||
'💩',
|
||||
'💔',
|
||||
'👎',
|
||||
'😠',
|
||||
'😞',
|
||||
'🤬',
|
||||
'🤢',
|
||||
'🤮',
|
||||
'🖕',
|
||||
'😡',
|
||||
'💢',
|
||||
'😠',
|
||||
'💀'
|
||||
],
|
||||
shortCodes: [
|
||||
':poop:',
|
||||
':shit:',
|
||||
':poo:',
|
||||
':hankey:',
|
||||
':pile_of_poo:',
|
||||
':broken_heart:',
|
||||
':thumbsdown:',
|
||||
':thumbdown:',
|
||||
':nauseated_face:',
|
||||
':sick:',
|
||||
':face_vomiting:',
|
||||
':vomiting_face:',
|
||||
':face_with_open_mouth_vomiting:',
|
||||
':middle_finger:',
|
||||
':rage:',
|
||||
':anger:',
|
||||
':anger_symbol:',
|
||||
':angry_face:',
|
||||
':angry:',
|
||||
':smiling_face_with_sunglasses:',
|
||||
':sunglasses:',
|
||||
':skull:',
|
||||
':skeleton:'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
514
src/contexts/NDKContext.tsx
Normal file
@ -0,0 +1,514 @@
|
||||
import NDK, {
|
||||
getRelayListForUser,
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKList,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKUser,
|
||||
zapInvoiceFromEvent
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'
|
||||
import { MOD_FILTER_LIMIT, T_TAG_VALUE } from 'constants.ts'
|
||||
import { Dexie } from 'dexie'
|
||||
import { createContext, ReactNode, useEffect, useMemo } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ModDetails, MuteLists, UserProfile, UserRelaysType } from 'types'
|
||||
import {
|
||||
constructModListFromEvents,
|
||||
hexToNpub,
|
||||
log,
|
||||
LogType,
|
||||
npubToHex,
|
||||
orderEventsChronologically,
|
||||
timeout
|
||||
} from 'utils'
|
||||
|
||||
type FetchModsOptions = {
|
||||
source?: string
|
||||
until?: number
|
||||
since?: number
|
||||
limit?: number
|
||||
author?: string
|
||||
}
|
||||
|
||||
export interface NDKContextType {
|
||||
ndk: NDK
|
||||
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
|
||||
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
|
||||
fetchEvent: (filter: NDKFilter) => Promise<NDKEvent | null>
|
||||
fetchEventsFromUserRelays: (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => Promise<NDKEvent[]>
|
||||
fetchEventFromUserRelays: (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => Promise<NDKEvent | null>
|
||||
findMetadata: (pubkey: string) => Promise<UserProfile>
|
||||
getTotalZapAmount: (
|
||||
user: string,
|
||||
eTag: string,
|
||||
aTag?: string,
|
||||
currentLoggedInUser?: string
|
||||
) => Promise<{
|
||||
accumulatedZapAmount: number
|
||||
hasZapped: boolean
|
||||
}>
|
||||
publish: (event: NDKEvent) => Promise<string[]>
|
||||
getNSFWList: () => Promise<string[]>
|
||||
getMuteLists: (pubkey?: string) => Promise<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}>
|
||||
}
|
||||
|
||||
// Create the context with an initial value of `null`
|
||||
export const NDKContext = createContext<NDKContextType | null>(null)
|
||||
|
||||
// Create a provider component to wrap around parts of your app
|
||||
export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
useEffect(() => {
|
||||
window.onunhandledrejection = async (event: PromiseRejectionEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.reason?.name === Dexie.errnames.DatabaseClosed) {
|
||||
console.log(
|
||||
'Could not open Dexie DB, probably version change. Deleting old DB and reloading...'
|
||||
)
|
||||
await Dexie.delete('degmod-db')
|
||||
// Must reload to open a brand new DB
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addAdminRelays = async (ndk: NDK) => {
|
||||
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
adminNpubs.forEach((npub) => {
|
||||
const hexKey = npubToHex(npub)
|
||||
if (hexKey) {
|
||||
getRelayListForUser(hexKey, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) {
|
||||
ndkRelayList.bothRelayUrls.forEach((url) =>
|
||||
ndk.addExplicitRelay(url)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ndk = useMemo(() => {
|
||||
localStorage.setItem('debug', '*')
|
||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
||||
dexieAdapter.locking = true
|
||||
const ndk = new NDK({
|
||||
enableOutboxModel: true,
|
||||
autoConnectUserRelays: true,
|
||||
autoFetchUserMutelist: true,
|
||||
explicitRelayUrls: [
|
||||
'wss://user.kindpag.es',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io/',
|
||||
import.meta.env.VITE_APP_RELAY
|
||||
],
|
||||
cacheAdapter: dexieAdapter
|
||||
})
|
||||
addAdminRelays(ndk)
|
||||
|
||||
ndk.connect()
|
||||
|
||||
return ndk
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Fetches a list of mods based on the provided source.
|
||||
*
|
||||
* @param source - The source URL to filter the mods. If it matches the current window location,
|
||||
* it adds a filter condition to the request.
|
||||
* @param until - Optional timestamp to filter events until this time.
|
||||
* @param since - Optional timestamp to filter events from this time.
|
||||
* @returns A promise that resolves to an array of `ModDetails` objects. In case of an error,
|
||||
* it logs the error and shows a notification, then returns an empty array.
|
||||
*/
|
||||
const fetchMods = async ({
|
||||
source,
|
||||
until,
|
||||
since,
|
||||
limit,
|
||||
author
|
||||
}: FetchModsOptions): Promise<ModDetails[]> => {
|
||||
// Define the filter criteria for fetching mods
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Classified], // Specify the kind of events to fetch
|
||||
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
|
||||
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
|
||||
if (source === window.location.host) {
|
||||
filter['#r'] = [window.location.host] // Add a tag filter for the current host
|
||||
}
|
||||
|
||||
return ndk
|
||||
.fetchEvents(filter, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
orderEventsChronologically(ndkEvents)
|
||||
|
||||
// Convert the fetched events into a list of mods
|
||||
const modList = constructModListFromEvents(ndkEvents)
|
||||
return modList // Return the list of mods
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log the error and show a notification if fetching fails
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in fetching mods from relays',
|
||||
err
|
||||
)
|
||||
toast.error('An error occurred in fetching mods from relays') // Show error notification
|
||||
return [] // Return an empty array in case of an error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple event based on a provided filter.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @returns Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
const fetchEvents = async (filter: NDKFilter): Promise<NDKEvent[]> => {
|
||||
return ndk
|
||||
.fetchEvents(filter, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
return orderEventsChronologically(ndkEvents)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log the error and show a notification if fetching fails
|
||||
log(true, LogType.Error, 'An error occurred in fetching events', err)
|
||||
toast.error('An error occurred in fetching events') // Show error notification
|
||||
return [] // Return an empty array in case of an error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves an event based on a provided filter.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @returns Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
const fetchEvent = async (filter: NDKFilter) => {
|
||||
const events = await fetchEvents(filter)
|
||||
if (events.length === 0) return null
|
||||
return events[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves with an array of events.
|
||||
*/
|
||||
const fetchEventsFromUserRelays = async (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
): Promise<NDKEvent[]> => {
|
||||
// Find the user's relays (10s timeout).
|
||||
const relayUrls = await Promise.race([
|
||||
getRelayListForUser(hexKey, ndk),
|
||||
timeout(3000)
|
||||
])
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[userRelaysType]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
return ndk
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
|
||||
relayUrls.length
|
||||
? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
: undefined
|
||||
)
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
return orderEventsChronologically(ndkEvents)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log the error and show a notification if fetching fails
|
||||
log(true, LogType.Error, 'An error occurred in fetching events', err)
|
||||
toast.error('An error occurred in fetching events') // Show error notification
|
||||
return [] // Return an empty array in case of an error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an event from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves to the fetched event or null if the operation fails.
|
||||
*/
|
||||
const fetchEventFromUserRelays = async (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => {
|
||||
const events = await fetchEventsFromUserRelays(
|
||||
filter,
|
||||
hexKey,
|
||||
userRelaysType
|
||||
)
|
||||
if (events.length === 0) return null
|
||||
return events[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds metadata for a given pubkey.
|
||||
*
|
||||
* @param hexKey - The pubkey to search for metadata.
|
||||
* @returns A promise that resolves to the metadata event.
|
||||
*/
|
||||
const findMetadata = async (pubkey: string): Promise<UserProfile> => {
|
||||
const npub = hexToNpub(pubkey)
|
||||
|
||||
const user = new NDKUser({ npub })
|
||||
user.ndk = ndk
|
||||
|
||||
const userProfile = await user.fetchProfile()
|
||||
|
||||
return userProfile
|
||||
}
|
||||
|
||||
const getTotalZapAmount = async (
|
||||
user: string,
|
||||
eTag: string,
|
||||
aTag?: string,
|
||||
currentLoggedInUser?: string
|
||||
) => {
|
||||
const filters: NDKFilter[] = [
|
||||
{
|
||||
kinds: [NDKKind.Zap],
|
||||
'#e': [eTag],
|
||||
'#p': [user]
|
||||
}
|
||||
]
|
||||
|
||||
if (aTag) {
|
||||
filters.push({
|
||||
kinds: [NDKKind.Zap],
|
||||
'#a': [aTag],
|
||||
'#p': [user]
|
||||
})
|
||||
}
|
||||
|
||||
const zapEvents = await fetchEventsFromUserRelays(
|
||||
filters,
|
||||
user,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
let accumulatedZapAmount = 0
|
||||
let hasZapped = false
|
||||
|
||||
zapEvents.forEach((zap) => {
|
||||
const zapInvoice = zapInvoiceFromEvent(zap)
|
||||
if (zapInvoice) {
|
||||
accumulatedZapAmount += Math.round(zapInvoice.amount / 1000)
|
||||
|
||||
if (!hasZapped) hasZapped = zapInvoice.zappee === currentLoggedInUser
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
accumulatedZapAmount,
|
||||
hasZapped
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async (event: NDKEvent): Promise<string[]> => {
|
||||
if (!event.sig) throw new Error('Before publishing first sign the event!')
|
||||
|
||||
return event
|
||||
.publish(undefined, 10000)
|
||||
.then((res) => {
|
||||
const relaysPublishedOn = Array.from(res)
|
||||
return relaysPublishedOn.map((relay) => relay.url)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`An error occurred in publishing event`, err)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin.
|
||||
*
|
||||
* @returns {Promise<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
|
||||
*/
|
||||
const getNSFWList = async (): Promise<string[]> => {
|
||||
// Initialize an array to store the NSFW post identifiers
|
||||
const nsfwPosts: string[] = []
|
||||
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
// Convert the public key (npub) to a hexadecimal format
|
||||
const hexKey = npubToHex(reportingNpub)
|
||||
|
||||
// If the conversion is successful and we have a hexKey
|
||||
if (hexKey) {
|
||||
// Fetch the event that contains the NSFW list
|
||||
const nsfwListEvent = await fetchEvent({
|
||||
kinds: [NDKKind.ArticleCurationSet],
|
||||
authors: [hexKey],
|
||||
'#d': ['nsfw']
|
||||
})
|
||||
|
||||
if (nsfwListEvent) {
|
||||
// Convert the event data to an NDKList, which is a structured list format
|
||||
const list = NDKList.from(nsfwListEvent)
|
||||
|
||||
// Iterate through the items in the list
|
||||
list.items.forEach((item) => {
|
||||
if (item[0] === 'a') {
|
||||
// Add the identifier of the NSFW post to the nsfwPosts array
|
||||
nsfwPosts.push(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return the array of NSFW post identifiers
|
||||
return nsfwPosts
|
||||
}
|
||||
|
||||
const getMuteLists = async (
|
||||
pubkey?: string
|
||||
): Promise<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}> => {
|
||||
const adminMutedAuthors = new Set<string>()
|
||||
const adminMutedPosts = new Set<string>()
|
||||
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
const adminHexKey = npubToHex(reportingNpub)
|
||||
|
||||
if (adminHexKey) {
|
||||
const muteListEvent = await fetchEvent({
|
||||
kinds: [NDKKind.MuteList],
|
||||
authors: [adminHexKey]
|
||||
})
|
||||
|
||||
if (muteListEvent) {
|
||||
const list = NDKList.from(muteListEvent)
|
||||
|
||||
list.items.forEach((item) => {
|
||||
if (item[0] === 'p') {
|
||||
adminMutedAuthors.add(item[1])
|
||||
} else if (item[0] === 'a') {
|
||||
adminMutedPosts.add(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const userMutedAuthors = new Set<string>()
|
||||
const userMutedPosts = new Set<string>()
|
||||
|
||||
if (pubkey) {
|
||||
const userHexKey = npubToHex(pubkey)
|
||||
|
||||
if (userHexKey) {
|
||||
const muteListEvent = await fetchEvent({
|
||||
kinds: [NDKKind.MuteList],
|
||||
authors: [userHexKey]
|
||||
})
|
||||
|
||||
if (muteListEvent) {
|
||||
const list = NDKList.from(muteListEvent)
|
||||
|
||||
list.items.forEach((item) => {
|
||||
if (item[0] === 'p') {
|
||||
userMutedAuthors.add(item[1])
|
||||
} else if (item[0] === 'a') {
|
||||
userMutedPosts.add(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
admin: {
|
||||
authors: Array.from(adminMutedAuthors),
|
||||
replaceableEvents: Array.from(adminMutedPosts)
|
||||
},
|
||||
user: {
|
||||
authors: Array.from(userMutedAuthors),
|
||||
replaceableEvents: Array.from(userMutedPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NDKContext.Provider
|
||||
value={{
|
||||
ndk,
|
||||
fetchMods,
|
||||
fetchEvents,
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
fetchEventFromUserRelays,
|
||||
findMetadata,
|
||||
getTotalZapAmount,
|
||||
publish,
|
||||
getNSFWList,
|
||||
getMuteLists
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NDKContext.Provider>
|
||||
)
|
||||
}
|
@ -1,2 +1 @@
|
||||
export * from './metadata'
|
||||
export * from './relay'
|
||||
export * from './zap'
|
||||
|
@ -1,127 +0,0 @@
|
||||
import NDK, { NDKList, NDKRelayList, NDKUser } from '@nostr-dev-kit/ndk'
|
||||
import { UserProfile } from '../types/user'
|
||||
import { hexToNpub, log, LogType, npubToHex } from '../utils'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { MuteLists } from '../types'
|
||||
|
||||
/**
|
||||
* Singleton class to manage metadata operations using NDK.
|
||||
*/
|
||||
export class MetadataController {
|
||||
private static instance: MetadataController
|
||||
private ndk: NDK
|
||||
public adminNpubs: string[]
|
||||
public adminRelays = new Set<string>()
|
||||
|
||||
private constructor() {
|
||||
this.ndk = new NDK({
|
||||
explicitRelayUrls: [
|
||||
'wss://user.kindpag.es',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io/'
|
||||
]
|
||||
})
|
||||
this.ndk.connect()
|
||||
|
||||
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
}
|
||||
|
||||
private setAdminRelays = async () => {
|
||||
const promises = this.adminNpubs.map((npub) => {
|
||||
const hexKey = npubToHex(npub)
|
||||
if (!hexKey) return null
|
||||
|
||||
return NDKRelayList.forUser(hexKey, this.ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) {
|
||||
ndkRelayList.writeRelayUrls.forEach((url) =>
|
||||
this.adminRelays.add(url)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the singleton instance of MetadataController.
|
||||
*
|
||||
* @returns The singleton instance of MetadataController.
|
||||
*/
|
||||
public static async getInstance(): Promise<MetadataController> {
|
||||
if (!MetadataController.instance) {
|
||||
MetadataController.instance = new MetadataController()
|
||||
|
||||
MetadataController.instance.setAdminRelays()
|
||||
}
|
||||
return MetadataController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds metadata for a given pubkey.
|
||||
*
|
||||
* @param hexKey - The pubkey to search for metadata.
|
||||
* @returns A promise that resolves to the metadata event.
|
||||
*/
|
||||
public findMetadata = async (pubkey: string): Promise<UserProfile> => {
|
||||
const npub = hexToNpub(pubkey)
|
||||
const user = new NDKUser({ npub })
|
||||
user.ndk = this.ndk
|
||||
|
||||
return await user.fetchProfile()
|
||||
}
|
||||
|
||||
public findWriteRelays = async (hexKey: string) => {
|
||||
const ndkRelayList = await NDKRelayList.forUser(hexKey, this.ndk)
|
||||
|
||||
if (!ndkRelayList) {
|
||||
throw new Error(`Couldn't found user's relay list`)
|
||||
}
|
||||
|
||||
return ndkRelayList.writeRelayUrls
|
||||
}
|
||||
|
||||
public getAdminsMuteLists = async (): Promise<MuteLists> => {
|
||||
// Create a Set to collect all unique muted authors
|
||||
|
||||
const mutedAuthors = new Set<string>()
|
||||
|
||||
// Create an array of promises to fetch mute lists for each npub
|
||||
const promises = this.adminNpubs.map(async (npub) => {
|
||||
const hexKey = npubToHex(npub)
|
||||
if (!hexKey) return
|
||||
|
||||
const muteListEvent = await this.ndk.fetchEvent({
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexKey]
|
||||
})
|
||||
|
||||
if (muteListEvent) {
|
||||
const list = NDKList.from(muteListEvent)
|
||||
|
||||
list.items.forEach((item) => {
|
||||
// Add muted authors to the Set directly
|
||||
if (item[0] === 'p') {
|
||||
mutedAuthors.add(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
|
||||
return {
|
||||
authors: Array.from(mutedAuthors),
|
||||
eventIds: []
|
||||
}
|
||||
}
|
||||
}
|
@ -1,239 +0,0 @@
|
||||
import { Event, Filter, Relay } from 'nostr-tools'
|
||||
import { log, LogType, normalizeWebSocketURL, timeout } from '../utils'
|
||||
import { MetadataController } from './metadata'
|
||||
|
||||
/**
|
||||
* Singleton class to manage relay operations.
|
||||
*/
|
||||
export class RelayController {
|
||||
private static instance: RelayController
|
||||
private debug = true
|
||||
public connectedRelays: Relay[] = []
|
||||
|
||||
private constructor() {}
|
||||
|
||||
private connectRelay = async (relayUrl: string) => {
|
||||
const relay = this.connectedRelays.find(
|
||||
(relay) =>
|
||||
normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl)
|
||||
)
|
||||
if (relay) {
|
||||
// already connected, skip
|
||||
return relay
|
||||
}
|
||||
|
||||
return await Relay.connect(relayUrl)
|
||||
.then((relay) => {
|
||||
log(this.debug, LogType.Info, `✅ nostr (${relayUrl}): Connected!`)
|
||||
this.connectedRelays.push(relay)
|
||||
return relay
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Error,
|
||||
`❌ nostr (${relayUrl}): Connection error!`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the singleton instance of RelayController.
|
||||
*
|
||||
* @returns The singleton instance of RelayController.
|
||||
*/
|
||||
public static getInstance(): RelayController {
|
||||
if (!RelayController.instance) {
|
||||
RelayController.instance = new RelayController()
|
||||
}
|
||||
return RelayController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an event to multiple relays.
|
||||
*
|
||||
* This method connects to the application relay and a set of write relays
|
||||
* obtained from the `MetadataController`. It then publishes the event to
|
||||
* all connected relays and returns a list of relays where the event was successfully published.
|
||||
*
|
||||
* @param event - The event to be published.
|
||||
* @returns A promise that resolves to an array of URLs of relays where the event was published,
|
||||
* or an empty array if no relays were connected or the event could not be published.
|
||||
*/
|
||||
publish = async (event: Event): Promise<string[]> => {
|
||||
// Connect to the application relay specified by environment variable
|
||||
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
// todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
// Retrieve the list of write relays for the event's public key
|
||||
// Use a timeout to handle cases where retrieving write relays takes too long
|
||||
const writeRelaysPromise = metadataController.findWriteRelays(event.pubkey)
|
||||
|
||||
log(this.debug, LogType.Info, `ℹ Finding user's write relays`)
|
||||
|
||||
// Use Promise.race to either get the write relay URLs or timeout
|
||||
const writeRelayUrls = await Promise.race([
|
||||
writeRelaysPromise,
|
||||
timeout() // This is a custom timeout function that rejects the promise after a specified time
|
||||
]).catch((err) => {
|
||||
log(this.debug, LogType.Error, err)
|
||||
return [] as string[] // Return an empty array if an error occurs
|
||||
})
|
||||
|
||||
// push admin relay urls obtained from metadata controller to writeRelayUrls list
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
writeRelayUrls.push(url)
|
||||
})
|
||||
|
||||
// Connect to all write relays obtained from MetadataController
|
||||
const relayPromises = writeRelayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
// Wait for all relay connections to settle (either fulfilled or rejected)
|
||||
await Promise.allSettled([appRelayPromise, ...relayPromises])
|
||||
|
||||
// Check if any relays are connected; if not, log an error and return null
|
||||
if (this.connectedRelays.length === 0) {
|
||||
log(this.debug, LogType.Error, 'No relay is connected!')
|
||||
return []
|
||||
}
|
||||
|
||||
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
|
||||
|
||||
// Create a promise for publishing the event to each connected relay
|
||||
const publishPromises = this.connectedRelays.map((relay) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Sending event:`,
|
||||
event
|
||||
)
|
||||
|
||||
return Promise.race([
|
||||
relay.publish(event), // Publish the event to the relay
|
||||
timeout(30000) // Set a timeout to handle cases where publishing takes too long
|
||||
])
|
||||
.then((res) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Publish result:`,
|
||||
res
|
||||
)
|
||||
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Error,
|
||||
`❌ nostr (${relay.url}): Publish error!`,
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all publish operations to complete (either fulfilled or rejected)
|
||||
await Promise.allSettled(publishPromises)
|
||||
|
||||
// Return the list of relay URLs where the event was published
|
||||
return publishedOnRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
|
||||
* If no relays are specified, it defaults to using connected relays.
|
||||
*
|
||||
* @param {Filter} filter - The filter criteria to find the event.
|
||||
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
|
||||
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
fetchEvents = async (
|
||||
filter: Filter,
|
||||
relays: string[] = []
|
||||
): Promise<Event[]> => {
|
||||
// add app relay to relays array
|
||||
relays.push(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
// add admin relays to relays array
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
relays.push(url)
|
||||
})
|
||||
|
||||
// Connect to all specified relays
|
||||
const relayPromises = relays.map((relayUrl) => this.connectRelay(relayUrl))
|
||||
await Promise.allSettled(relayPromises)
|
||||
|
||||
// Check if any relays are connected
|
||||
if (this.connectedRelays.length === 0) {
|
||||
log(this.debug, LogType.Error, 'No relay is connected to fetch events!')
|
||||
throw new Error('No relay is connected to fetch events!')
|
||||
}
|
||||
|
||||
const events: Event[] = []
|
||||
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subPromises = this.connectedRelays.map((relay) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Subscribe to the relay with the specified filter
|
||||
const sub = relay.subscribe([filter], {
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Add the event to the array if it's not a duplicate
|
||||
if (!eventIds.has(e.id)) {
|
||||
eventIds.add(e.id) // Record the event ID
|
||||
events.push(e) // Add the event to the array
|
||||
}
|
||||
},
|
||||
// Handle the End-Of-Stream (EOSE) message
|
||||
oneose: () => {
|
||||
sub.close() // Close the subscription
|
||||
resolve() // Resolve the promise when EOSE is received
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all subscriptions to complete
|
||||
await Promise.allSettled(subPromises)
|
||||
|
||||
// It is possible that different relays will send different events and events array may contain more events then specified limit in filter
|
||||
// To fix this issue we'll first sort these events and then return only limited events
|
||||
if (filter.limit) {
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
return events.slice(0, filter.limit)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves an event from a set of relays based on a provided filter.
|
||||
* If no relays are specified, it defaults to using connected relays.
|
||||
*
|
||||
* @param {Filter} filter - The filter criteria to find the event.
|
||||
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
|
||||
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
fetchEvent = async (
|
||||
filter: Filter,
|
||||
relays: string[] = []
|
||||
): Promise<Event | null> => {
|
||||
const events = await this.fetchEvents(filter, relays)
|
||||
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Return the most recent event, or null if no events were received
|
||||
return events[0] || null
|
||||
}
|
||||
}
|
362
src/controllers/zap.ts
Normal file
@ -0,0 +1,362 @@
|
||||
import { Invoice } from '@getalby/lightning-tools'
|
||||
import NDK, {
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln'
|
||||
import {
|
||||
isLnurlResponse,
|
||||
LnurlResponse,
|
||||
PaymentRequest,
|
||||
SignedEvent,
|
||||
ZapReceipt,
|
||||
ZapRequest
|
||||
} from '../types'
|
||||
import { log, LogType, npubToHex } from '../utils'
|
||||
|
||||
/**
|
||||
* Singleton class to manage zap related operations.
|
||||
*/
|
||||
export class ZapController {
|
||||
private static instance: ZapController
|
||||
private webln: WebLNProvider | null = null
|
||||
private httpClient: AxiosInstance
|
||||
private appRelay = import.meta.env.VITE_APP_RELAY
|
||||
|
||||
private constructor() {
|
||||
this.httpClient = axios.create()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The singleton instance of ZapController.
|
||||
*/
|
||||
public static getInstance(): ZapController {
|
||||
if (!ZapController.instance) {
|
||||
ZapController.instance = new ZapController()
|
||||
}
|
||||
return ZapController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates ZapRequest and payment request string. More info can be found at
|
||||
* https://github.com/nostr-protocol/nips/blob/master/57.md.
|
||||
* @param lud16 - LUD-16 of the recipient.
|
||||
* @param amount - payment amount (will be multiplied by 1000 to represent sats).
|
||||
* @param recipientPubKey - pubKey of the recipient.
|
||||
* @param recipientRelays - relays on which zap receipt will be published.
|
||||
* @param senderPubkey - pubKey of of the sender.
|
||||
* @param content - optional content (comment).
|
||||
* @param eventId - event id, if zapping an event.
|
||||
* @param aTag - value of `a` tag.
|
||||
* @returns - promise that resolves into object containing zap request and payment
|
||||
* request string
|
||||
*/
|
||||
async getLightningPaymentRequest(
|
||||
lud16: string,
|
||||
amount: number,
|
||||
recipientPubKey: string,
|
||||
recipientRelays: string[],
|
||||
senderPubkey: string,
|
||||
content?: string,
|
||||
eventId?: string,
|
||||
aTag?: string
|
||||
) {
|
||||
// Check if amount is greater than 0
|
||||
if (amount <= 0) throw 'Amount should be > 0.'
|
||||
|
||||
// convert to mili satoshis
|
||||
amount *= 1000
|
||||
|
||||
// decode lud16 into lnurl
|
||||
const lnurl = this.decodeLud16(lud16)
|
||||
|
||||
// get receiver lightning details from lnurl pay endpoint
|
||||
const lnurlResponse = await this.getLnurlResponse(lnurl)
|
||||
|
||||
const { minSendable, maxSendable, callback } = lnurlResponse
|
||||
|
||||
// check if the amount is within minSendable and maxSendable values
|
||||
if (amount < minSendable || amount > maxSendable) {
|
||||
throw `Amount '${amount}' is not within minSendable and maxSendable values '${minSendable}-${maxSendable}'.`
|
||||
}
|
||||
|
||||
// generate zap request
|
||||
const zapRequest = await this.createZapRequest(
|
||||
amount,
|
||||
content,
|
||||
recipientPubKey,
|
||||
recipientRelays,
|
||||
senderPubkey,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
|
||||
if (!window.nostr?.signEvent) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'Failed to sign the zap request!',
|
||||
'window.nostr.signEvent is not defined'
|
||||
)
|
||||
throw 'Failed to sign zap Request!'
|
||||
}
|
||||
|
||||
// Sign zap request. This is validated by the lightning provider prior to sending the invoice(NIP-57).
|
||||
const signedEvent = await window.nostr
|
||||
.signEvent(zapRequest)
|
||||
.then((event) => event as SignedEvent)
|
||||
.catch((err) => {
|
||||
log(true, LogType.Error, 'Failed to sign the zap request!', err)
|
||||
throw 'Failed to sign the zap request!'
|
||||
})
|
||||
|
||||
// Kind 9734 event must be signed and sent
|
||||
// in order to receive the invoice from the provider.
|
||||
// Encode stringified signed zap request.
|
||||
const encodedEvent = encodeURI(JSON.stringify(signedEvent))
|
||||
|
||||
// send zap request as GET request to callback url received from the lnurl pay endpoint
|
||||
const { data } = await this.httpClient.get(
|
||||
`${callback}?amount=${amount}&nostr=${encodedEvent}`
|
||||
)
|
||||
|
||||
// data object of the response should contain payment request
|
||||
if (data && data.pr) {
|
||||
return Promise.resolve({ ...signedEvent, pr: data.pr })
|
||||
}
|
||||
|
||||
throw 'lnurl callback did not return payment request.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls zap receipt.
|
||||
* @param paymentRequest - payment request object containing zap request and
|
||||
* payment request string.
|
||||
* @param pollingTimeout - polling timeout (secs), by default equals to 6min.
|
||||
* @returns - promise that resolves into zap receipt.
|
||||
*/
|
||||
async pollZapReceipt(
|
||||
paymentRequest: PaymentRequest,
|
||||
ndk: NDK,
|
||||
pollingTimeout?: number
|
||||
) {
|
||||
const { pr, ...zapRequest } = paymentRequest
|
||||
const { created_at } = zapRequest
|
||||
|
||||
// stringify zap request
|
||||
const zapRequestStringified = JSON.stringify(zapRequest)
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<ZapReceipt>(async (resolve, reject) => {
|
||||
// clear polling timeout
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
subscription.stop()
|
||||
}
|
||||
|
||||
// Polling timeout
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
cleanup()
|
||||
|
||||
reject('Zap receipt was not received.')
|
||||
},
|
||||
pollingTimeout || 6 * 60 * 1000 // 6 minutes
|
||||
)
|
||||
|
||||
const relaysTag = zapRequest.tags.find((t) => t[0] === 'relays')
|
||||
if (!relaysTag)
|
||||
throw new Error('Zap request does not contain relays tag.')
|
||||
|
||||
const relayUrls = relaysTag.slice(1)
|
||||
|
||||
// filter relay for event of kind 9735
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Zap],
|
||||
since: created_at
|
||||
}
|
||||
|
||||
const subscription = ndk.subscribe(
|
||||
filter,
|
||||
{
|
||||
closeOnEose: false,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
|
||||
},
|
||||
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
)
|
||||
|
||||
subscription.on('event', async (ndkEvent) => {
|
||||
// compare description tag of the event with stringified zap request
|
||||
if (ndkEvent.tagValue('description') === zapRequestStringified) {
|
||||
// validate zap receipt
|
||||
if (
|
||||
await this.validateZapReceipt(pr, ndkEvent.rawEvent() as ZapReceipt)
|
||||
) {
|
||||
cleanup()
|
||||
|
||||
resolve(ndkEvent.rawEvent() as ZapReceipt)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
subscription.start()
|
||||
})
|
||||
}
|
||||
|
||||
async isWeblnProviderExists(): Promise<boolean> {
|
||||
await this.requestWeblnProvider()
|
||||
return !!this.webln
|
||||
}
|
||||
|
||||
async sendPayment(invoice: string): Promise<SendPaymentResponse> {
|
||||
if (this.webln) {
|
||||
return await this.webln!.sendPayment(invoice).catch((err) => {
|
||||
throw new Error(`Error while sending payment. Error: ${err.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
throw 'Webln is not defined!'
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes LUD-16 into lnurl.
|
||||
* @param lud16 - LUD-16 that looks like <username>@<domainname>.
|
||||
* @returns - lnurl that looks like 'http://<domain>/.well-known/lnurlp/<username>'.
|
||||
*/
|
||||
private decodeLud16(lud16: string) {
|
||||
const username = lud16.split('@')[0]
|
||||
const domain = lud16.split('@')[1]
|
||||
|
||||
if (!domain || !username) throw `Provided lud16 '${lud16}' is not valid.`
|
||||
|
||||
return `https://${domain}/.well-known/lnurlp/${username}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and validates response from lnurl pay endpoint.
|
||||
*
|
||||
* @param lnurl - lnurl pay endpoint.
|
||||
* @returns response object that conforms to LnurlResponse interface.
|
||||
*/
|
||||
private async getLnurlResponse(lnurl: string): Promise<LnurlResponse> {
|
||||
// get request from lnurl pay endpoint
|
||||
const { data: lnurlResponse } = await this.httpClient.get(lnurl)
|
||||
|
||||
// validate lnurl response
|
||||
this.validateLnurlResponse(lnurlResponse)
|
||||
|
||||
// return callback URL
|
||||
return Promise.resolve(lnurlResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if response conforms to LnurlResponse interface and if 'allowsNostr'
|
||||
* and 'nostrPubkey' supported.
|
||||
*
|
||||
* @param response - response received from lnurl pay endpoint.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private validateLnurlResponse(response: any) {
|
||||
if (!isLnurlResponse(response)) {
|
||||
throw 'Provided response is not LnurlResponse.'
|
||||
}
|
||||
|
||||
if (!response.allowsNostr) throw `'allowsNostr' is not supported.`
|
||||
if (!response.nostrPubkey) throw `'nostrPubkey' is not supported.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs zap request object.
|
||||
* @param amount - request amount (sats).
|
||||
* @param content - comment.
|
||||
* @param recipientPubKey - pubKey of the recipient.
|
||||
* @param recipientRelays - relays on which zap receipt will be published.
|
||||
* @param senderPubkey - pubKey of of the sender.
|
||||
* @param eventId - event id, if zapping an event.
|
||||
* @param aTag - value of `a` tag.
|
||||
* @returns zap request
|
||||
*/
|
||||
private async createZapRequest(
|
||||
amount: number,
|
||||
content = '',
|
||||
recipientPubKey: string,
|
||||
recipientRelays: string[],
|
||||
senderPubkey: string,
|
||||
eventId?: string,
|
||||
aTag?: string
|
||||
): Promise<ZapRequest> {
|
||||
const recipientHexKey = npubToHex(recipientPubKey)
|
||||
|
||||
if (!recipientHexKey) throw 'Invalid recipient pubKey.'
|
||||
|
||||
if (!recipientRelays.includes(this.appRelay)) {
|
||||
recipientRelays.push(this.appRelay)
|
||||
}
|
||||
|
||||
const zapRequest: ZapRequest = {
|
||||
kind: kinds.ZapRequest,
|
||||
content,
|
||||
tags: [
|
||||
['relays', ...recipientRelays],
|
||||
['amount', `${amount}`],
|
||||
['p', recipientHexKey]
|
||||
],
|
||||
pubkey: senderPubkey,
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// add event id to the tags, if zapping an event.
|
||||
if (eventId) zapRequest.tags.push(['e', eventId])
|
||||
|
||||
if (aTag) zapRequest.tags.push(['a', aTag])
|
||||
|
||||
return zapRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates zap receipt preimage and payment request string
|
||||
* @param paymentRequest - payment request string
|
||||
* @param event - zap receipt.
|
||||
* @returns - boolean indicating if preimage in zap receipt is valid
|
||||
*/
|
||||
private async validateZapReceipt(paymentRequest: string, event: ZapReceipt) {
|
||||
const invoice = new Invoice({ pr: paymentRequest })
|
||||
|
||||
return invoice.validatePreimage(this.getPreimageFromZapReceipt(event))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets preimage from zap receipt.
|
||||
* @param event - zap receipt (9735 kind).
|
||||
* @returns - preimage string.
|
||||
*/
|
||||
private getPreimageFromZapReceipt(event: ZapReceipt) {
|
||||
// filter tags by 1st item
|
||||
const preimageTag = event.tags.filter((tag) => tag[0] === 'preimage')[0]
|
||||
|
||||
// throw an error if 'preimage' tag is not present
|
||||
if (!preimageTag || preimageTag.length != 2) {
|
||||
throw `'preimage' tag is not present.`
|
||||
}
|
||||
|
||||
const preimage = preimageTag[1]
|
||||
|
||||
// throw an error if 'preimage' value is not present
|
||||
if (!preimage) throw `'preimage' tag is not valid.`
|
||||
|
||||
return preimage
|
||||
}
|
||||
|
||||
private async requestWeblnProvider() {
|
||||
if (!this.webln)
|
||||
this.webln = await requestProvider().catch((err) => {
|
||||
console.log('err in requesting WebLNProvider :>> ', err.message)
|
||||
return null
|
||||
})
|
||||
}
|
||||
}
|
@ -1,2 +1,10 @@
|
||||
export * from './redux'
|
||||
export * from './useDidMount'
|
||||
export * from './useFilteredMods'
|
||||
export * from './useGames'
|
||||
export * from './useMuteLists'
|
||||
export * from './useNSFWList'
|
||||
export * from './useReactions'
|
||||
export * from './useNDKContext'
|
||||
export * from './useScrollDisable'
|
||||
export * from './useLocalStorage'
|
||||
|
111
src/hooks/useComments.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
getRelayListForUser,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKRelaySet,
|
||||
NDKSubscription,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CommentEvent, UserRelaysType } from 'types'
|
||||
import { log, LogType, timeout } from 'utils'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useComments = (
|
||||
author: string | undefined,
|
||||
aTag: string | undefined
|
||||
) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(author && aTag)) {
|
||||
// Author and aTag are required
|
||||
return
|
||||
}
|
||||
|
||||
let subscription: NDKSubscription // Define the subscription variable here for cleanup
|
||||
|
||||
const setupSubscription = async () => {
|
||||
// Find the mod author's relays.
|
||||
|
||||
const authorReadRelays = await Promise.race([
|
||||
getRelayListForUser(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
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
'#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
|
||||
},
|
||||
relayUrls.size
|
||||
? NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
|
||||
: undefined
|
||||
)
|
||||
|
||||
subscription.on('event', (ndkEvent) => {
|
||||
setCommentEvents((prev) => {
|
||||
if (prev.find((e) => e.id === ndkEvent.id)) {
|
||||
return [...prev]
|
||||
}
|
||||
|
||||
const commentEvent: CommentEvent = {
|
||||
kind: NDKKind.Text,
|
||||
tags: ndkEvent.tags,
|
||||
content: ndkEvent.content,
|
||||
created_at: ndkEvent.created_at!,
|
||||
pubkey: ndkEvent.pubkey,
|
||||
id: ndkEvent.id,
|
||||
sig: ndkEvent.sig!
|
||||
}
|
||||
|
||||
return [commentEvent, ...prev]
|
||||
})
|
||||
})
|
||||
|
||||
subscription.start()
|
||||
}
|
||||
|
||||
setupSubscription()
|
||||
|
||||
// Cleanup function to stop the subscription on unmount
|
||||
return () => {
|
||||
if (subscription) {
|
||||
subscription.stop()
|
||||
}
|
||||
}
|
||||
}, [aTag, author, ndk])
|
||||
|
||||
return {
|
||||
commentEvents,
|
||||
setCommentEvents
|
||||
}
|
||||
}
|
147
src/hooks/useFilteredMods.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { useMemo } from 'react'
|
||||
import { IUserState } from 'store/reducers/user'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
MuteLists,
|
||||
NSFWFilter,
|
||||
SortBy,
|
||||
WOTFilterOptions
|
||||
} from 'types'
|
||||
import { npubToHex } from 'utils'
|
||||
import { useAppSelector } from './redux'
|
||||
import { isInWoT } from 'utils/wot'
|
||||
|
||||
export const useFilteredMods = (
|
||||
mods: ModDetails[],
|
||||
userState: IUserState,
|
||||
filterOptions: FilterOptions,
|
||||
nsfwList: string[],
|
||||
muteLists: {
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
},
|
||||
author?: string | undefined
|
||||
) => {
|
||||
const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector(
|
||||
(state) => state.wot
|
||||
)
|
||||
|
||||
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:
|
||||
// If 'Hide_NSFW' is selected, filter out NSFW mods
|
||||
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
|
||||
case NSFWFilter.Show_NSFW:
|
||||
// If 'Show_NSFW' is selected, return all mods (no filtering)
|
||||
return mods
|
||||
case NSFWFilter.Only_NSFW:
|
||||
// If 'Only_NSFW' is selected, filter to show only NSFW mods
|
||||
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
|
||||
}
|
||||
}
|
||||
|
||||
const wotFilter = (mods: ModDetails[]) => {
|
||||
// Determine the filtering logic based on the WOT filter option and user state
|
||||
// when user is not logged in use Site_Only
|
||||
if (!userState.auth) {
|
||||
return mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
}
|
||||
// when user is logged, allow other filter selections
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
switch (filterOptions.wot) {
|
||||
case WOTFilterOptions.None:
|
||||
// Only admins can choose None, use siteWoT for others
|
||||
return isWoTNpub
|
||||
? mods
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Exclude:
|
||||
// Only admins can choose Exlude, use siteWot for others
|
||||
// Exlude returns the mods not in the site's WoT
|
||||
return isWoTNpub
|
||||
? mods.filter((mod) => !isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Site_Only:
|
||||
return mods.filter((mod) =>
|
||||
isInWoT(siteWot, siteWotLevel, mod.author)
|
||||
)
|
||||
case WOTFilterOptions.Mine_Only:
|
||||
// Only admins can choose Mine_Only, use siteWoT for others
|
||||
return isWoTNpub
|
||||
? mods.filter((mod) => isInWoT(userWot, userWotLevel, mod.author))
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Site_And_Mine:
|
||||
return mods.filter(
|
||||
(mod) =>
|
||||
isInWoT(siteWot, siteWotLevel, mod.author) ||
|
||||
isInWoT(userWot, userWotLevel, mod.author)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = nsfwFilter(mods)
|
||||
|
||||
filtered = wotFilter(filtered)
|
||||
|
||||
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"
|
||||
// Allow "Unmoderated Fully" when author visits own profile
|
||||
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.admin.authors.includes(mod.author) &&
|
||||
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.moderated === ModeratedFilter.Moderated) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.user.authors.includes(mod.author) &&
|
||||
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortBy.Latest) {
|
||||
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||
} else if (filterOptions.sort === SortBy.Oldest) {
|
||||
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [
|
||||
userState.auth,
|
||||
userState.user?.npub,
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
filterOptions.wot,
|
||||
filterOptions.nsfw,
|
||||
author,
|
||||
mods,
|
||||
muteLists,
|
||||
nsfwList,
|
||||
siteWot,
|
||||
siteWotLevel,
|
||||
userWot,
|
||||
userWotLevel
|
||||
])
|
||||
}
|
82
src/hooks/useGames.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import Papa from 'papaparse'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Game } from 'types'
|
||||
import { log, LogType } from 'utils'
|
||||
import gameFiles from '../utils/games'
|
||||
|
||||
export const useGames = () => {
|
||||
const hasProcessedFiles = useRef(false)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasProcessedFiles.current) return
|
||||
|
||||
hasProcessedFiles.current = true
|
||||
|
||||
const readGamesCSVs = async () => {
|
||||
const uniqueGames: Game[] = []
|
||||
const gameNames = new Set<string>()
|
||||
|
||||
// Function to promisify PapaParse
|
||||
const parseCSV = (csvText: string) =>
|
||||
new Promise<Game[]>((resolve, reject) => {
|
||||
Papa.parse<Game>(csvText, {
|
||||
worker: true,
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length) {
|
||||
reject(results.errors)
|
||||
}
|
||||
|
||||
resolve(results.data)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
// Fetch and parse each file
|
||||
const promises = Object.values(gameFiles).map(async (importFn) => {
|
||||
const csvText = await importFn()
|
||||
if (typeof csvText === 'string') {
|
||||
const parsedGames = await parseCSV(csvText as string)
|
||||
|
||||
// Remove duplicate games based on 'Game Name'
|
||||
parsedGames.forEach((game) => {
|
||||
if (!gameNames.has(game['Game Name'])) {
|
||||
gameNames.add(game['Game Name'])
|
||||
uniqueGames.push(game)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
setGames(uniqueGames)
|
||||
} catch (err) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in reading and parsing games CSVs',
|
||||
err
|
||||
)
|
||||
|
||||
// Handle the unknown error type
|
||||
if (err instanceof Error) {
|
||||
toast.error(err.message)
|
||||
} else if (Array.isArray(err) && err.length > 0 && err[0]?.message) {
|
||||
// Handle the case when it's an array of PapaParse errors
|
||||
toast.error(err[0].message)
|
||||
} else {
|
||||
toast.error(
|
||||
'An unknown error occurred in reading and parsing csv files'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readGamesCSVs()
|
||||
}, [])
|
||||
|
||||
return games
|
||||
}
|
68
src/hooks/useLocalStorage.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem
|
||||
} from 'utils'
|
||||
|
||||
const useLocalStorageSubscribe = (callback: () => void) => {
|
||||
window.addEventListener('storage', callback)
|
||||
return () => window.removeEventListener('storage', callback)
|
||||
}
|
||||
|
||||
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
|
||||
if (typeof storedValue === 'object' && storedValue !== null) {
|
||||
return { ...initialValue, ...storedValue }
|
||||
}
|
||||
return storedValue
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const getSnapshot = () => {
|
||||
// Get the stored value
|
||||
const storedValue = getLocalStorageItem(key, initialValue)
|
||||
|
||||
// Parse the value
|
||||
const parsedStoredValue = JSON.parse(storedValue)
|
||||
|
||||
// Merge the default and the stored in case some of the required fields are missing
|
||||
return JSON.stringify(
|
||||
mergeWithInitialValue(parsedStoredValue, 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)
|
||||
}
|
||||
},
|
||||
[data, key]
|
||||
)
|
||||
|
||||
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]
|
||||
}
|
32
src/hooks/useMuteLists.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MuteLists } from 'types'
|
||||
import { useAppSelector } from './redux'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useMuteLists = () => {
|
||||
const { getMuteLists } = useNDKContext()
|
||||
const [muteLists, setMuteLists] = useState<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}>({
|
||||
admin: {
|
||||
authors: [],
|
||||
replaceableEvents: []
|
||||
},
|
||||
user: {
|
||||
authors: [],
|
||||
replaceableEvents: []
|
||||
}
|
||||
})
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
const pubkey = userState.user?.pubkey as string | undefined
|
||||
getMuteLists(pubkey).then((lists) => {
|
||||
setMuteLists(lists)
|
||||
})
|
||||
}, [userState, getMuteLists])
|
||||
|
||||
return muteLists
|
||||
}
|
13
src/hooks/useNDKContext.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useNDKContext = () => {
|
||||
const ndkContext = useContext(NDKContext)
|
||||
|
||||
if (!ndkContext)
|
||||
throw new Error(
|
||||
'NDKContext should not be used in out component tree hierarchy'
|
||||
)
|
||||
|
||||
return { ...ndkContext } as NDKContextType
|
||||
}
|
16
src/hooks/useNSFWList.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useDidMount } from './useDidMount'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useNSFWList = () => {
|
||||
const { getNSFWList } = useNDKContext()
|
||||
const [nsfwList, setNSFWList] = useState<string[]>([])
|
||||
|
||||
useDidMount(async () => {
|
||||
getNSFWList().then((list) => {
|
||||
setNSFWList(list)
|
||||
})
|
||||
})
|
||||
|
||||
return nsfwList
|
||||
}
|
18
src/hooks/useProfile.tsx
Normal 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
|
||||
}
|
177
src/hooks/useReactions.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import { REACTIONS } from 'constants.ts'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
|
||||
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, timeout } from 'utils'
|
||||
|
||||
type UseReactionsParams = {
|
||||
pubkey: string
|
||||
eTag: string
|
||||
aTag?: string
|
||||
}
|
||||
|
||||
export const useReactions = (params: UseReactionsParams) => {
|
||||
const { ndk, fetchEventsFromUserRelays, publish } = useNDKContext()
|
||||
const [isReactionInProgress, setIsReactionInProgress] = useState(false)
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false)
|
||||
const [reactionEvents, setReactionEvents] = useState<NDKEvent[]>([])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(() => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Reaction]
|
||||
}
|
||||
|
||||
if (params.aTag) {
|
||||
filter['#a'] = [params.aTag]
|
||||
} else {
|
||||
filter['#e'] = [params.eTag]
|
||||
}
|
||||
|
||||
// 1 minute timeout
|
||||
Promise.race([
|
||||
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
|
||||
timeout(60000)
|
||||
])
|
||||
.then((events) => {
|
||||
setReactionEvents(events)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDataLoaded(true)
|
||||
})
|
||||
})
|
||||
|
||||
const hasReactedPositively = useMemo(() => {
|
||||
return (
|
||||
!!userState.auth &&
|
||||
reactionEvents.some(
|
||||
(event) =>
|
||||
event.pubkey === userState.user?.pubkey &&
|
||||
(REACTIONS.positive.emojis.includes(event.content) ||
|
||||
REACTIONS.positive.shortCodes.includes(event.content))
|
||||
)
|
||||
)
|
||||
}, [reactionEvents, userState])
|
||||
|
||||
const hasReactedNegatively = useMemo(() => {
|
||||
return (
|
||||
!!userState.auth &&
|
||||
reactionEvents.some(
|
||||
(event) =>
|
||||
event.pubkey === userState.user?.pubkey &&
|
||||
(REACTIONS.negative.emojis.includes(event.content) ||
|
||||
REACTIONS.negative.shortCodes.includes(event.content))
|
||||
)
|
||||
)
|
||||
}, [reactionEvents, userState])
|
||||
|
||||
const getPubkey = async () => {
|
||||
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('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
return hexPubkey
|
||||
}
|
||||
|
||||
const handleReaction = async (isPositive?: boolean) => {
|
||||
if (!isDataLoaded || hasReactedPositively || hasReactedNegatively) return
|
||||
|
||||
if (isReactionInProgress) return
|
||||
|
||||
setIsReactionInProgress(true)
|
||||
|
||||
try {
|
||||
const pubkey = await getPubkey()
|
||||
if (!pubkey) return
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: kinds.Reaction,
|
||||
created_at: now(),
|
||||
content: isPositive ? '+' : '-',
|
||||
pubkey,
|
||||
tags: [
|
||||
['e', params.eTag],
|
||||
['p', params.pubkey]
|
||||
]
|
||||
}
|
||||
|
||||
if (params.aTag) {
|
||||
unsignedEvent.tags.push(['a', params.aTag])
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the reaction event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
|
||||
setReactionEvents((prev) => [...prev, ndkEvent])
|
||||
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'Failed to publish reaction event on any relay'
|
||||
)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
setIsReactionInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { likesCount, disLikesCount } = useMemo(() => {
|
||||
let positiveCount = 0
|
||||
let negativeCount = 0
|
||||
|
||||
reactionEvents.forEach((event) => {
|
||||
if (
|
||||
REACTIONS.positive.emojis.includes(event.content) ||
|
||||
REACTIONS.positive.shortCodes.includes(event.content)
|
||||
) {
|
||||
positiveCount++
|
||||
} else if (
|
||||
REACTIONS.negative.emojis.includes(event.content) ||
|
||||
REACTIONS.negative.shortCodes.includes(event.content)
|
||||
) {
|
||||
negativeCount++
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
likesCount: abbreviateNumber(positiveCount),
|
||||
disLikesCount: abbreviateNumber(negativeCount)
|
||||
}
|
||||
}, [reactionEvents])
|
||||
|
||||
return {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively,
|
||||
handleReaction
|
||||
}
|
||||
}
|
11
src/hooks/useScrollDisable.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useBodyScrollDisable = (disable: boolean) => {
|
||||
useEffect(() => {
|
||||
if (disable) document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [disable])
|
||||
}
|
@ -43,10 +43,12 @@ a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
/*
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
*/
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
|
10
src/layout/feed.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export const FeedLayout = () => {
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<h1>WIP</h1>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -16,7 +16,7 @@ export const Footer = () => {
|
||||
by
|
||||
<a
|
||||
className={styles.secMainFooterParaLink}
|
||||
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
||||
href='https://degmods.com/profile/nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
|
||||
target='_blank'
|
||||
>
|
||||
Freakoverse
|
||||
@ -25,14 +25,6 @@ export const Footer = () => {
|
||||
<a className={styles.secMainFooterParaLink} href='backers.html'>
|
||||
Supporters
|
||||
</a>
|
||||
and Nostr developer
|
||||
<a
|
||||
className={styles.secMainFooterParaLink}
|
||||
href='https://nostrdev.com'
|
||||
target='_blank'
|
||||
>
|
||||
NostrDev
|
||||
</a>
|
||||
. Check our
|
||||
<a className={styles.secMainFooterParaLink} href='backup.html'>
|
||||
Backup Plan
|
||||
|
36
src/layout/head.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Helmet } from 'react-helmet'
|
||||
import thumb from '../assets/img/DEGM Thumb.png'
|
||||
import logoWithCircle from '../assets/img/Logo with circle.png'
|
||||
|
||||
export const Head = () => {
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Open Graph Meta Tags */}
|
||||
<meta property='og:title' content='DEG Mods - Liberating Game Mods' />
|
||||
<meta
|
||||
property='og:description'
|
||||
content='Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely.'
|
||||
/>
|
||||
<meta property='og:image' content={thumb} />
|
||||
<meta property='og:url' content='https://degmods.com' />
|
||||
<meta property='og:type' content='website' />
|
||||
|
||||
{/* Twitter Card Meta Tags */}
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta name='twitter:title' content='DEG Mods - Liberating Game Mods' />
|
||||
<meta
|
||||
name='twitter:description'
|
||||
content='Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely.'
|
||||
/>
|
||||
<meta name='twitter:image' content={thumb} />
|
||||
|
||||
{/* Other Meta Tags */}
|
||||
<meta
|
||||
name='description'
|
||||
content='Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely.'
|
||||
/>
|
||||
|
||||
<link rel='icon' type='image/png' sizes='935x934' href={logoWithCircle} />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
@ -1,53 +1,86 @@
|
||||
import navStyles from '../styles/nav.module.scss'
|
||||
import mainStyles from '../styles//main.module.scss'
|
||||
import { Banner } from '../components/Banner'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appRoutes } from '../routes'
|
||||
import {
|
||||
init as initNostrLogin,
|
||||
launch as launchNostrLoginDialog
|
||||
} from 'nostr-login'
|
||||
import { useEffect } from 'react'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import { setIsAuth, setUser } from '../store/reducers/user'
|
||||
import { MetadataController } from '../controllers'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Banner } from '../components/Banner'
|
||||
import { ZapPopUp } from '../components/Zap'
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
import { appRoutes } from '../routes'
|
||||
import { setAuth, setUser } from '../store/reducers/user'
|
||||
import mainStyles from '../styles//main.module.scss'
|
||||
import navStyles from '../styles/nav.module.scss'
|
||||
import '../styles/popup.css'
|
||||
import { npubToHex } from '../utils'
|
||||
import logo from '../assets/img/DEG Mods Logo With Text.svg'
|
||||
import placeholder from '../assets/img/DEG Mods Default PP.png'
|
||||
import { resetUserWot } from 'store/reducers/wot'
|
||||
|
||||
export const Header = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { findMetadata } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
// Track nostr-login extension modal open state
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const handleOpen = () => setIsOpen(true)
|
||||
const handleClose = () => setIsOpen(false)
|
||||
useEffect(() => {
|
||||
window.addEventListener('nlCloseModal', handleClose)
|
||||
return () => {
|
||||
window.removeEventListener('nlCloseModal', handleClose)
|
||||
}
|
||||
}, [])
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useEffect(() => {
|
||||
initNostrLogin({
|
||||
darkMode: true,
|
||||
localSignup: true,
|
||||
noBanner: true,
|
||||
methods: ['extension'],
|
||||
onAuth: (npub, opts) => {
|
||||
if (opts.type === 'logout') {
|
||||
dispatch(setIsAuth(false))
|
||||
dispatch(setAuth(null))
|
||||
dispatch(setUser(null))
|
||||
dispatch(resetUserWot())
|
||||
} else {
|
||||
dispatch(setIsAuth(true))
|
||||
dispatch(setUser({ npub }))
|
||||
MetadataController.getInstance().then((metadataController) => {
|
||||
metadataController.findMetadata(npub).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
dispatch(
|
||||
setUser({
|
||||
npub,
|
||||
pubkey: npubToHex(npub)!,
|
||||
...userProfile
|
||||
})
|
||||
)
|
||||
}
|
||||
dispatch(
|
||||
setAuth({
|
||||
method: opts.method,
|
||||
localNsec: opts.localNsec
|
||||
})
|
||||
)
|
||||
dispatch(
|
||||
setUser({
|
||||
npub,
|
||||
pubkey: npubToHex(npub)!
|
||||
})
|
||||
)
|
||||
findMetadata(npub).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
dispatch(
|
||||
setUser({
|
||||
npub,
|
||||
pubkey: npubToHex(npub)!,
|
||||
...userProfile
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [dispatch])
|
||||
}, [dispatch, findMetadata])
|
||||
|
||||
const handleLogin = () => {
|
||||
handleOpen()
|
||||
launchNostrLoginDialog()
|
||||
}
|
||||
|
||||
@ -65,27 +98,14 @@ export const Header = () => {
|
||||
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
|
||||
<img
|
||||
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
|
||||
src='/assets/img/DEG%20Mods%20Logo%20With%20Text.svg'
|
||||
src={logo}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={navStyles.NMTI_Sec}>
|
||||
<div className={navStyles.NMTI_SecInside}>
|
||||
<a
|
||||
className={`${navStyles.NMTI_SecInside_Link} ${navStyles.NMTI_SI_LinkTip}`}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
Tip
|
||||
</a>
|
||||
<TipButtonWithDialog />
|
||||
<Link
|
||||
to={appRoutes.submitMod}
|
||||
className={navStyles.NMTI_SecInside_Link}
|
||||
@ -131,20 +151,23 @@ export const Header = () => {
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
{!userState.isAuth && (
|
||||
<a
|
||||
id='loginNav'
|
||||
className={navStyles.NMTI_SecInside_Link}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
<img
|
||||
className={navStyles.NMTI_SecInside_LinkImg}
|
||||
src='/assets/img/DEG%20Mods%20Default%20PP.png'
|
||||
/>
|
||||
Login
|
||||
</a>
|
||||
{!userState.auth && (
|
||||
<>
|
||||
<RegisterButtonWithDialog />
|
||||
<a
|
||||
id='loginNav'
|
||||
className={navStyles.NMTI_SecInside_Link}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
<img
|
||||
className={navStyles.NMTI_SecInside_LinkImg}
|
||||
src={placeholder}
|
||||
/>
|
||||
Login
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{userState.isAuth && userState.user && (
|
||||
{userState.auth && userState.user && (
|
||||
<div className={navStyles.NMTI_SecInside_Link}>
|
||||
{userState.user.image && (
|
||||
<img
|
||||
@ -168,33 +191,300 @@ export const Header = () => {
|
||||
<div className={navStyles.NavMainBottom}>
|
||||
<div className={mainStyles.ContainerMain}>
|
||||
<div className={navStyles.NavMainBottomInside}>
|
||||
<Link
|
||||
to={appRoutes.games}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
<div
|
||||
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}
|
||||
></div>
|
||||
<div className={navStyles.NavMainBottomInsideLinks}>
|
||||
<Link
|
||||
to={appRoutes.games}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Games
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.mods}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Mods
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.about}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.blogs}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}
|
||||
>
|
||||
Games
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.mods}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Mods
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.about}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.blog}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://degmods.com/profile/nprofile1qqs0f0clkkagh6pe7ux8xvtn8ccf77qgy2e3ra37q8uaez4mks5034gfw4xg6'
|
||||
target='_blank'
|
||||
>
|
||||
<img
|
||||
src='https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif'
|
||||
width='15px'
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://x.com/DEGMods'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://www.youtube.com/@DEGModsDotCom'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TipButtonWithDialog = React.memo(() => {
|
||||
const [adminNpub, setAdminNpub] = useState<string | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(async () => {
|
||||
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
setAdminNpub(adminNpubs[0])
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
className={`${navStyles.NMTI_SecInside_Link} ${navStyles.NMTI_SI_LinkTip}`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
Tip
|
||||
</a>
|
||||
{isOpen && adminNpub && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap DEG Mods'
|
||||
receiver={adminNpub}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
labelDescriptionMain={
|
||||
<p className='labelDescriptionMain' style={{ textAlign: 'center' }}>
|
||||
If you don't want the development and maintenance of DEG Mods to
|
||||
stop, then a tip helps!
|
||||
</p>
|
||||
}
|
||||
lastNode={
|
||||
<div className='BTCAddressPopZap'>
|
||||
<p>
|
||||
DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '}
|
||||
<a
|
||||
href='https://youtu.be/payDPlHzp58?t=215'
|
||||
className='linkMain'
|
||||
target='_blank'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
):
|
||||
<br />
|
||||
<span className='BTCAddressPopZapTextSpan'>
|
||||
sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const RegisterButtonWithDialog = () => {
|
||||
const [showPopUp, setShowPopUp] = useState(false)
|
||||
|
||||
useBodyScrollDisable(showPopUp)
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
id='registerNav'
|
||||
className={navStyles.NMTI_SecInside_Link}
|
||||
onClick={() => setShowPopUp(true)}
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
{showPopUp && (
|
||||
<div id='PopUpMainRegister' className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Create an account via</h3>
|
||||
</div>
|
||||
<div
|
||||
className='popUpMainCardTopClose'
|
||||
onClick={() => setShowPopUp(false)}
|
||||
>
|
||||
<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'>
|
||||
Browser Extensions (Windows)
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
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'
|
||||
role='button'
|
||||
href='https://chromewebstore.google.com/detail/nostr-connect/ampjiinddmggbhpebhaegmjkbbeofoaj'
|
||||
target='_blank'
|
||||
>
|
||||
Nostr Connect
|
||||
</a>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
role='button'
|
||||
href='https://addons.mozilla.org/en-US/firefox/addon/nostr-connect/'
|
||||
target='_blank'
|
||||
>
|
||||
Nostr Connect (Firefox)
|
||||
</a>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
role='button'
|
||||
href='https://keys.band/'
|
||||
target='_blank'
|
||||
>
|
||||
Keys.Band
|
||||
</a>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
role='button'
|
||||
href='https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp'
|
||||
target='_blank'
|
||||
>
|
||||
nos2x
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
className='labelDescriptionMain'
|
||||
style={{
|
||||
padding: '10px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
border: 'solid 1px rgba(255,255,255,0.1)',
|
||||
margin: '10px 0 0 0'
|
||||
}}
|
||||
>
|
||||
Q: Why can't I create an account normally?
|
||||
<br />
|
||||
A: DEG Mods can't ban you or delete your content (we can
|
||||
only hide you), and the consequence of that is this kind of
|
||||
registration/login system.
|
||||
</p>
|
||||
<div className='dividerPopup'>
|
||||
<div className='dividerPopupLine'></div>
|
||||
<p>or</p>
|
||||
<div className='dividerPopupLine'></div>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Browser Extensions (iOS)
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
Once you create your "account" on any of these, come
|
||||
back and click login, then sign-in with extension.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
role='button'
|
||||
href='https://apps.apple.com/us/app/nostore/id1666553677'
|
||||
target='_blank'
|
||||
>
|
||||
Nostore Browser Extension
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,13 +1,127 @@
|
||||
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'
|
||||
import { Head } from './head'
|
||||
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
|
||||
import { useEffect } from 'react'
|
||||
import { npubToHex } from 'utils'
|
||||
import { calculateWot } from 'utils/wot'
|
||||
import {
|
||||
setSiteWot,
|
||||
setSiteWotLevel,
|
||||
setSiteWotStatus,
|
||||
setUserWot,
|
||||
setUserWotLevel,
|
||||
WOTStatus
|
||||
} from 'store/reducers/wot'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { UserRelaysType } from 'types'
|
||||
|
||||
export const Layout = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { ndk, fetchEventFromUserRelays } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { siteWotStatus } = useAppSelector((state) => state.wot)
|
||||
|
||||
// calculate site's wot
|
||||
useEffect(() => {
|
||||
if (ndk) {
|
||||
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
|
||||
const hexPubkey = npubToHex(SITE_WOT_NPUB)
|
||||
if (hexPubkey) {
|
||||
dispatch(setSiteWotStatus(WOTStatus.LOADING))
|
||||
calculateWot(hexPubkey, ndk)
|
||||
.then((wot) => {
|
||||
dispatch(setSiteWot(wot))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.trace('An error occurred in calculating site WOT', err)
|
||||
toast.error('An error occurred in calculating site web-of-trust!')
|
||||
dispatch(setSiteWotStatus(WOTStatus.FAILED))
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ndk, dispatch])
|
||||
|
||||
// calculate user's wot
|
||||
useEffect(() => {
|
||||
if (ndk && userState.user?.pubkey) {
|
||||
const hexPubkey = npubToHex(userState.user.pubkey as string)
|
||||
if (hexPubkey)
|
||||
calculateWot(hexPubkey, ndk)
|
||||
.then((wot) => {
|
||||
dispatch(setUserWot(wot))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.trace('An error occurred in calculating user WOT', err)
|
||||
toast.error('An error occurred in calculating user web-of-trust!')
|
||||
})
|
||||
}
|
||||
}, [ndk, userState.user, dispatch])
|
||||
|
||||
// get site's wot level
|
||||
useEffect(() => {
|
||||
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
|
||||
const hexPubkey = npubToHex(SITE_WOT_NPUB)
|
||||
if (hexPubkey) {
|
||||
fetchEventFromUserRelays(
|
||||
{
|
||||
kinds: [NDKKind.AppSpecificData],
|
||||
'#d': ['degmods'],
|
||||
authors: [hexPubkey]
|
||||
},
|
||||
hexPubkey,
|
||||
UserRelaysType.Both
|
||||
).then((event) => {
|
||||
if (event) {
|
||||
const wot = event.tagValue('wot')
|
||||
if (wot) dispatch(setSiteWotLevel(parseInt(wot)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [dispatch, fetchEventFromUserRelays])
|
||||
|
||||
// get user's wot level
|
||||
useEffect(() => {
|
||||
if (userState.user?.pubkey) {
|
||||
const hexPubkey = npubToHex(userState.user.pubkey as string)
|
||||
|
||||
if (hexPubkey) {
|
||||
fetchEventFromUserRelays(
|
||||
{
|
||||
kinds: [NDKKind.AppSpecificData],
|
||||
'#d': ['degmods'],
|
||||
authors: [hexPubkey]
|
||||
},
|
||||
hexPubkey,
|
||||
UserRelaysType.Both
|
||||
).then((event) => {
|
||||
if (event) {
|
||||
const wot = event.tagValue('wot')
|
||||
if (wot) dispatch(setUserWotLevel(parseInt(wot)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [userState.user, dispatch, fetchEventFromUserRelays])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head />
|
||||
<Header />
|
||||
<Outlet />
|
||||
{siteWotStatus === WOTStatus.LOADED && <Outlet />}
|
||||
{siteWotStatus === WOTStatus.LOADING && (
|
||||
<LoadingSpinner desc="Loading site's web-of-trust" />
|
||||
)}
|
||||
{siteWotStatus === WOTStatus.FAILED && (
|
||||
<h3>Failed to load site's web-of-trust</h3>
|
||||
)}
|
||||
<Footer />
|
||||
<SocialNav />
|
||||
<ScrollRestoration />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
114
src/layout/socialNav.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { NavLink, NavLinkProps } from 'react-router-dom'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import 'styles/socialNav.css'
|
||||
import { npubToHex } from 'utils'
|
||||
|
||||
export const SocialNav = () => {
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='socialNav'
|
||||
style={{
|
||||
transform: isCollapsed ? 'translateX(0)' : 'translateX(50%)',
|
||||
right: isCollapsed ? '0%' : '50%'
|
||||
}}
|
||||
>
|
||||
<div className='socialNavInsideWrapper'>
|
||||
{!isCollapsed && (
|
||||
<div className='socialNavInside'>
|
||||
<NavButton
|
||||
to={appRoutes.home}
|
||||
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.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.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}
|
||||
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'
|
||||
/>
|
||||
{!!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}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-128 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='socialNavCollapseIcon'
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(180deg)'
|
||||
}}
|
||||
>
|
||||
<path d='M192 448c-8.188 0-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l137.4 137.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavButtonProps extends NavLinkProps {
|
||||
svgPath: string
|
||||
viewBox?: string
|
||||
}
|
||||
|
||||
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'
|
||||
viewBox={viewBox}
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d={svgPath}></path>
|
||||
</svg>
|
||||
</NavLink>
|
||||
)
|
@ -1,20 +1,20 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { store } from './store/index.ts'
|
||||
import { NDKContextProvider } from 'contexts/NDKContext.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</BrowserRouter>
|
||||
</NDKContextProvider>
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
40
src/pages/404.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -2,6 +2,8 @@ import { FAQAccordion } from '../components/FAQAccordion'
|
||||
import '../styles/about.css'
|
||||
import '../styles/FAQ.css'
|
||||
import '../styles/styles.css'
|
||||
import thumb from '../assets/img/DEGM Thumb.png'
|
||||
import vivian from '../assets/img/vivian james.png'
|
||||
|
||||
export type FAQItem = {
|
||||
question: string
|
||||
@ -10,53 +12,68 @@ export type FAQItem = {
|
||||
|
||||
const FAQ_ITEMS: FAQItem[] = [
|
||||
{
|
||||
question: "You don't host mods files?",
|
||||
answer: "Nope. And that's for the better."
|
||||
question: "You don't host mod files?",
|
||||
answer: `We don't handle that directly, but you, as the creator, will.`
|
||||
},
|
||||
{
|
||||
question: 'How do you assure security on the files?',
|
||||
answer:
|
||||
"We don't, but you, as the user, do. You know how sometimes you go to some forums or social sites and someone shares a download link, you'd see someone asking 'is this link safe?' and people would reply with yes/no, etc. People will be doing that same process here, with a bit of help by having a simple reaction system for each link so you'd get a quicker idea on the safety of these links that's being provided by mod creators."
|
||||
question:
|
||||
'How do you assure security of game mod files that someone downloads?',
|
||||
answer: `We don't assure security directly. However, we will provide a reaction
|
||||
system to help users gauge the safety of download links, and mod creators
|
||||
are encouraged to include scan links.`
|
||||
},
|
||||
{
|
||||
question: "Why are you quoting 'account'?",
|
||||
answer:
|
||||
"Because technically you aren't creating an 'account', you're generating/creating/obtaining an address/key-pair. Check the next question/answer to get more details."
|
||||
answer: `We use 'account' in quotes because technically you're generating a
|
||||
key pair, not creating a traditional account. The next FAQ explains more.`
|
||||
},
|
||||
{
|
||||
question: "You 'can't' remove mods or ban accounts? How does that work?",
|
||||
answer:
|
||||
"I'll try my best to simplify the technicalities of this answer... Because of the nature of Nostr, the 'account' creation process involves the generation/obtaining two cryptographic key pairs, one private (think of that as your password that you cannot change), and one public (think of that as your username that you cannot change). These keypairs are coming from the Nostr protocol itself, and nobody controls Nostr, it's just there. Considering that, we can't 'ban' anyone directly. We might have a mute-list with public address that won't show their posts/submissions on this site, but they are still there and accessible by anyone. It's the same with someone's posts, we can't touch those as well. Gist: If someone put a gun to your / the team's head, will you censor or ban anyone? No, because we can't."
|
||||
answer: `I'll try my best to simplify the technicalities of this answer... Because of the nature of Nostr,
|
||||
the 'account' creation process involves the generation/obtaining two cryptographic key pairs,
|
||||
one private (think of that as your password that you cannot change), and one public (think of that
|
||||
as your username that you cannot change). These key pairs are coming from the Nostr protocol itself,
|
||||
and nobody controls Nostr, it's just there. Considering that, we can't 'ban' anyone directly. We might
|
||||
have a mute-list with public addresses that won't show their posts/submissions on this site, but they
|
||||
are still there and accessible by anyone. It's the same with someone's posts, we can't touch those as well.
|
||||
Gist: If someone put a gun to your / the team's head, will you censor or ban anyone? No, because we can't.`
|
||||
},
|
||||
{
|
||||
question:
|
||||
"You can't do anything about any mod or person? Nothing at all? What about the illegal stuff?",
|
||||
answer:
|
||||
"Directly removing the content can't be done, and directly 'banning' someone also can't be done. At most, pages/posts and people can be filtered out / hidden from the website, but people can still see the content with a quick copy/paste."
|
||||
answer: `Direct removal or banning is not possible. We can only filter or
|
||||
hide content on the site, but it remains accessible on here and elsewhere.`
|
||||
},
|
||||
{
|
||||
question:
|
||||
'Why did you have to add Bitcoin? Why not traditional payment methods like Visa, PayPal, etc?',
|
||||
answer:
|
||||
"For various reasons. With traditional payment methods, not everyone has access to them, they can pressure/threaten us or mod creators or even gamers to censor or ban or not use this site by holding our funds or take them away, they can prevent you from tipping on this site or specific mod creators, and there's no privacy, among other reasons. With Bitcoin, anyone has access to it, nobody controls it so you can't be threatened with/by it, you can actually own it so you control it, and with it you're Pseudonymous."
|
||||
answer: `For various reasons. With traditional payment methods, not everyone has access to them, they
|
||||
can pressure or threaten us, mod creators, or even gamers to censor or ban, or restrict usage of this site
|
||||
by holding our funds or stealing them. They can prevent you from tipping on this site or specific mod creators,
|
||||
and there's no privacy. These are just a few reasons why we aren't using traditional payment methods.
|
||||
With Bitcoin, anyone has access to it, nobody controls it so you can't be threatened with/by it,
|
||||
you can actually own and properly control it, and it provides pseudonymity.`
|
||||
},
|
||||
{
|
||||
question: 'Is this an open-source project?',
|
||||
answer: "Yes. Here's the repo."
|
||||
answer: `Yes, DEG Mods is open-source. You can access the code repository
|
||||
[here](https://github.com/your-repo).`
|
||||
},
|
||||
{
|
||||
question: "Who's developing / maintaining DEG Mods?",
|
||||
answer:
|
||||
'Considering this is an open-source project, anyone can contribute to its development and maintenance. With that said, the initial idea-tor, designer, and frontend developer is Freakoverse, the initial backend developer and Nostr implementor is NAME.'
|
||||
answer: `Considering this is an open-source project, anyone can contribute to its development and maintenance.
|
||||
With that said, the initial idea-tor, designer, and frontend developer is [Freakoverse](https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r), and the co-developer
|
||||
is [Nostr Dev](https://nostrdev.com/).`
|
||||
},
|
||||
{
|
||||
question: "Who's that character above with the orange hair?",
|
||||
answer: "That's Vivian James. A gamer that just wants to game in peace."
|
||||
answer: `That's Vivian James, a fictional gamer character.`
|
||||
},
|
||||
{
|
||||
question: "Who's that character above with the purple hair?",
|
||||
answer:
|
||||
"That's Moda-chan. DEG Mods' mascot. She's a master game mod creator! (Yes, she was AI generated, as such her design is temporary and will be replaced with a design created by an artist (or artists) when that time comes.)"
|
||||
answer: `That's Moda-chan. DEG Mods' mascot. She's a master game mod creator! (Yes, she was AI-generated,
|
||||
as such her design is temporary and will be replaced with a design created by an artist (or artists)
|
||||
when that time comes.)`
|
||||
}
|
||||
]
|
||||
|
||||
@ -80,7 +97,7 @@ export const AboutPage = () => {
|
||||
>
|
||||
Liberating Game Mods
|
||||
</h1>
|
||||
<img src='/assets/img/DEGM%20Thumb.png' alt='Thumbnail' />
|
||||
<img src={thumb} alt='Thumbnail' />
|
||||
<p className='LearnTextPara'>
|
||||
Never get your game mods censored, get banned, lose your
|
||||
history, nor lose the connection between creators and fans.
|
||||
@ -108,7 +125,11 @@ export const AboutPage = () => {
|
||||
imposing their ideals. DEG Mods aims to change that
|
||||
narrative by being developed on Nostr, a revolutionary new
|
||||
communications protocol.{' '}
|
||||
<a className='linkMain' href='#'>
|
||||
<a
|
||||
className='linkMain'
|
||||
href='https://nostr.com/'
|
||||
target='_blank'
|
||||
>
|
||||
Learn more about Nostr here.
|
||||
</a>
|
||||
<br />
|
||||
@ -159,9 +180,11 @@ export const AboutPage = () => {
|
||||
on this platform/site. Pretend its not even there. We're not
|
||||
even making any money out of this project/site, in-fact,
|
||||
we're running at a loss (unless direct donations/tips covers
|
||||
it). This is just a passion project to help free (liberate)
|
||||
game mods and their creators, and this part potentially
|
||||
helps them financially, even those in other countries where
|
||||
it, and/or we managed to add reasonable monetization systems
|
||||
to help cover further development and maintenance costs).
|
||||
This is just a passion project to help free (liberate) game
|
||||
mods and their creators, and this part potentially helps
|
||||
them financially, even those in other countries where
|
||||
"normal" methods of money payment/transfer are not an
|
||||
option. You can just find the mod you want and download it,
|
||||
or upload the mod you've created, and never even touch
|
||||
@ -214,10 +237,7 @@ export const AboutPage = () => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
className='LearnTextCharacterImgRight'
|
||||
src='/assets/img/vivian%20james.png'
|
||||
/>
|
||||
<img className='LearnTextCharacterImgRight' src={vivian} />
|
||||
</div>
|
||||
|
||||
<div className='LearnFAQ'>
|
||||
|
264
src/pages/blog/action.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { ActionFunctionArgs } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { store } from 'store'
|
||||
import { UserRelaysType } from 'types'
|
||||
import { log, LogType, now, signAndPublish } from 'utils'
|
||||
|
||||
export const blogRouteAction =
|
||||
(ndkContext: NDKContextType) =>
|
||||
async ({ params, request }: ActionFunctionArgs) => {
|
||||
const { naddr } = params
|
||||
if (!naddr) {
|
||||
log(true, LogType.Error, 'Required naddr.')
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode author from naddr
|
||||
let aTag: string | undefined
|
||||
try {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
aTag = `${kind}:${pubkey}:${identifier}`
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, 'Failed to decode naddr')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!aTag) {
|
||||
log(true, LogType.Error, 'Missing #a Tag')
|
||||
return null
|
||||
}
|
||||
|
||||
const userState = store.getState().user
|
||||
let hexPubkey: string
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!hexPubkey) {
|
||||
toast.error('Failed to get the pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
const isAdmin =
|
||||
userState.user?.npub &&
|
||||
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
const handleBlock = async () => {
|
||||
// Define the event filter to search for the user's mute list events.
|
||||
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexPubkey]
|
||||
}
|
||||
|
||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
||||
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
let unsignedEvent: UnsignedEvent
|
||||
if (muteListEvent) {
|
||||
// get a list of tags
|
||||
const tags = muteListEvent.tags
|
||||
const alreadyExists =
|
||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||
|
||||
if (alreadyExists) {
|
||||
toast.warn(`Blog reference is already in user's mute list`)
|
||||
return null
|
||||
}
|
||||
|
||||
tags.push(['a', aTag])
|
||||
|
||||
unsignedEvent = {
|
||||
pubkey: muteListEvent.pubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: muteListEvent.content,
|
||||
created_at: now(),
|
||||
tags: [...tags]
|
||||
}
|
||||
} else {
|
||||
unsignedEvent = {
|
||||
pubkey: hexPubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: '',
|
||||
created_at: now(),
|
||||
tags: [['a', aTag]]
|
||||
}
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
|
||||
if (!isUpdated) {
|
||||
toast.error("Failed to update user's mute list")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleUnblock = async () => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexPubkey]
|
||||
}
|
||||
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
if (!muteListEvent) {
|
||||
toast.error(`Couldn't get user's mute list event from relays`)
|
||||
return null
|
||||
}
|
||||
|
||||
const tags = muteListEvent.tags
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
pubkey: muteListEvent.pubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: muteListEvent.content,
|
||||
created_at: now(),
|
||||
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
if (!isUpdated) {
|
||||
toast.error("Failed to update user's mute list")
|
||||
}
|
||||
return null
|
||||
}
|
||||
const handleAddNSFW = async () => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Curationsets],
|
||||
authors: [hexPubkey],
|
||||
'#d': ['nsfw']
|
||||
}
|
||||
|
||||
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
let unsignedEvent: UnsignedEvent
|
||||
|
||||
if (nsfwListEvent) {
|
||||
const tags = nsfwListEvent.tags
|
||||
const alreadyExists =
|
||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||
|
||||
if (alreadyExists) {
|
||||
toast.warn(`Blog reference is already in user's nsfw list`)
|
||||
return null
|
||||
}
|
||||
|
||||
tags.push(['a', aTag])
|
||||
|
||||
unsignedEvent = {
|
||||
pubkey: nsfwListEvent.pubkey,
|
||||
kind: kinds.Curationsets,
|
||||
content: nsfwListEvent.content,
|
||||
created_at: now(),
|
||||
tags: [...tags]
|
||||
}
|
||||
} else {
|
||||
unsignedEvent = {
|
||||
pubkey: hexPubkey,
|
||||
kind: kinds.Curationsets,
|
||||
content: '',
|
||||
created_at: now(),
|
||||
tags: [
|
||||
['a', aTag],
|
||||
['d', 'nsfw']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
if (!isUpdated) {
|
||||
toast.error("Failed to update user's nsfw list")
|
||||
}
|
||||
return null
|
||||
}
|
||||
const handleRemoveNSFW = async () => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Curationsets],
|
||||
authors: [hexPubkey],
|
||||
'#d': ['nsfw']
|
||||
}
|
||||
|
||||
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
if (!nsfwListEvent) {
|
||||
toast.error(`Couldn't get nsfw list event from relays`)
|
||||
return null
|
||||
}
|
||||
|
||||
const tags = nsfwListEvent.tags
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
pubkey: nsfwListEvent.pubkey,
|
||||
kind: kinds.Curationsets,
|
||||
content: nsfwListEvent.content,
|
||||
created_at: now(),
|
||||
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
if (!isUpdated) {
|
||||
toast.error("Failed to update user's nsfw list")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const requestData = (await request.json()) as {
|
||||
intent: 'nsfw' | 'block'
|
||||
value: boolean
|
||||
}
|
||||
|
||||
switch (requestData.intent) {
|
||||
case 'block':
|
||||
await (requestData.value ? handleBlock() : handleUnblock())
|
||||
break
|
||||
|
||||
case 'nsfw':
|
||||
if (!isAdmin) {
|
||||
log(true, LogType.Error, 'Unable to update NSFW list. No permission')
|
||||
return null
|
||||
}
|
||||
await (requestData.value ? handleAddNSFW() : handleRemoveNSFW())
|
||||
break
|
||||
|
||||
default:
|
||||
log(true, LogType.Error, 'Missing intent for blog action')
|
||||
break
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
323
src/pages/blog/index.tsx
Normal file
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
91
src/pages/blog/report.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
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
|
||||
if (isSent) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
}, [fetcher, handleClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Report Post</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<fetcher.Form
|
||||
className='pUMCB_ZapsInside'
|
||||
method='post'
|
||||
action='report'
|
||||
>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Why are you reporting this?
|
||||
</label>
|
||||
{BLOG_REPORT_REASONS.map((r) => (
|
||||
<CheckboxFieldUncontrolled
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
name={r.key}
|
||||
defaultChecked={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className='btn btnMain pUMCB_Report'
|
||||
type='submit'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Submit Report
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
146
src/pages/blog/reportAction.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { ActionFunctionArgs } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { store } from 'store'
|
||||
import { UserRelaysType } from 'types'
|
||||
import {
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex,
|
||||
parseFormData,
|
||||
sendDMUsingRandomKey,
|
||||
signAndPublish
|
||||
} from 'utils'
|
||||
|
||||
export const blogReportRouteAction =
|
||||
(ndkContext: NDKContextType) =>
|
||||
async ({ params, request }: ActionFunctionArgs) => {
|
||||
const requestData = await request.formData()
|
||||
const { naddr } = params
|
||||
if (!naddr) {
|
||||
log(true, LogType.Error, 'Required naddr.')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode author from naddr
|
||||
let aTag: string | undefined
|
||||
try {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
aTag = `${kind}:${pubkey}:${identifier}`
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, 'Failed to decode naddr')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!aTag) {
|
||||
log(true, LogType.Error, 'Missing #a Tag')
|
||||
return false
|
||||
}
|
||||
|
||||
const userState = store.getState().user
|
||||
let hexPubkey: string | undefined
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
}
|
||||
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
const reportingPubkey = npubToHex(reportingNpub)
|
||||
|
||||
// Parse the the data
|
||||
const formSubmit = parseFormData(requestData)
|
||||
|
||||
const selectedOptionsCount = Object.values(formSubmit).filter(
|
||||
(checked) => checked === 'on'
|
||||
).length
|
||||
if (selectedOptionsCount === 0) {
|
||||
toast.error('At least one option should be checked!')
|
||||
return false
|
||||
}
|
||||
|
||||
if (reportingPubkey === hexPubkey) {
|
||||
// Define the event filter to search for the user's mute list events.
|
||||
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexPubkey]
|
||||
}
|
||||
|
||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
||||
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
let unsignedEvent: UnsignedEvent
|
||||
if (muteListEvent) {
|
||||
const tags = muteListEvent.tags
|
||||
const alreadyExists =
|
||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||
if (alreadyExists) {
|
||||
toast.warn(`Blog reference is already in user's mute list`)
|
||||
return false
|
||||
}
|
||||
tags.push(['a', aTag])
|
||||
unsignedEvent = {
|
||||
pubkey: muteListEvent.pubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: muteListEvent.content,
|
||||
created_at: now(),
|
||||
tags: [...tags]
|
||||
}
|
||||
} else {
|
||||
unsignedEvent = {
|
||||
pubkey: hexPubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: '',
|
||||
created_at: now(),
|
||||
tags: [['a', aTag]]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
hexPubkey = await window.nostr?.getPublicKey()
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'Could not get pubkey for reporting blog!',
|
||||
error
|
||||
)
|
||||
toast.error('Could not get pubkey for reporting blog!')
|
||||
return false
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isUpdated }
|
||||
} else {
|
||||
const href = window.location.href
|
||||
let message = `I'd like to report ${href} due to following reasons:\n`
|
||||
Object.entries(formSubmit).forEach(([key, value]) => {
|
||||
if (value === 'on') {
|
||||
message += `* ${key}\n`
|
||||
}
|
||||
})
|
||||
try {
|
||||
const isSent = await sendDMUsingRandomKey(
|
||||
message,
|
||||
reportingPubkey!,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isSent }
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, 'Failed to send a blog report', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
@ -1,143 +0,0 @@
|
||||
import { BlogCard } from '../components/BlogCard'
|
||||
import '../styles/filters.css'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
import '../styles/styles.css'
|
||||
|
||||
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</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='https://image.nostr.build/d6af39fb1d47feaf09831ddf9d447ccc435ba10fcbb9b6d6e800390f6bbac851.png' />
|
||||
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
|
||||
<BlogCard backgroundLink='https://pbs.twimg.com/media/GDrRJOOXYAAeysT.jpg:large' />
|
||||
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
</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
@ -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
@ -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
@ -0,0 +1,3 @@
|
||||
export const FeedPage = () => {
|
||||
return <h2>Feed</h2>
|
||||
}
|
205
src/pages/game.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import {
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
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, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { FilterOptions, ModDetails } from 'types'
|
||||
import {
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
extractModData,
|
||||
isModDataComplete,
|
||||
scrollIntoView
|
||||
} from 'utils'
|
||||
|
||||
export const GamePage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
const params = useParams()
|
||||
const { name: gameName } = params
|
||||
const { ndk } = useNDKContext()
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
// 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,
|
||||
muteLists
|
||||
)
|
||||
|
||||
// Pagination logic
|
||||
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 = filteredModList.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Classified],
|
||||
'#t': [T_TAG_VALUE]
|
||||
}
|
||||
|
||||
const subscription = ndk.subscribe(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
|
||||
closeOnEose: true
|
||||
})
|
||||
|
||||
subscription.on('event', (ndkEvent) => {
|
||||
if (isModDataComplete(ndkEvent)) {
|
||||
const mod = extractModData(ndkEvent)
|
||||
if (mod.game === gameName)
|
||||
setMods((prev) => {
|
||||
if (prev.find((e) => e.aTag === mod.aTag)) return [...prev]
|
||||
|
||||
return [...prev, mod]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
subscription.start()
|
||||
|
||||
// Cleanup function to stop subscription
|
||||
return () => {
|
||||
subscription.stop()
|
||||
}
|
||||
}, [gameName, ndk])
|
||||
|
||||
if (!gameName) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>
|
||||
Game:
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{gameName}
|
||||
</span>
|
||||
{searchTerm !== '' && (
|
||||
<>
|
||||
—
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{searchTerm}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<SearchInput
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
ref={searchTermRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModFilter />
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{currentMods.map((mod) => (
|
||||
<ModCard key={mod.id} {...mod} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaginationWithPageNumbers
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,85 +1,129 @@
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/styles.css'
|
||||
import '../styles/search.css'
|
||||
import { PaginationWithPageNumbers } from 'components/Pagination'
|
||||
import { MAX_GAMES_PER_PAGE } from 'constants.ts'
|
||||
import { useDidMount, useGames, useNDKContext } from 'hooks'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { GameCard } from '../components/GameCard'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
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)
|
||||
const navigate = useNavigate()
|
||||
const { fetchMods } = useNDKContext()
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
const games = useGames()
|
||||
const [gamesWithMods, setGamesWithMods] = useState<string[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
useDidMount(() => {
|
||||
fetchMods({ limit: 100, source: window.location.host }).then((mods) => {
|
||||
mods.sort((a, b) => b.published_at - a.published_at)
|
||||
|
||||
const gameNames = new Set<string>()
|
||||
|
||||
mods.map((mod) => gameNames.add(mod.game))
|
||||
setGamesWithMods(Array.from(gameNames))
|
||||
})
|
||||
})
|
||||
|
||||
const sortedGames = useMemo(() => {
|
||||
// Create a map for the order array, assigning each game name a rank based on its index.
|
||||
const orderMap = new Map<string, number>()
|
||||
gamesWithMods.forEach((gameName, index) => {
|
||||
orderMap.set(gameName, index)
|
||||
})
|
||||
|
||||
const gamesArray = [...games]
|
||||
|
||||
return gamesArray.sort((a, b) => {
|
||||
const indexA = orderMap.get(a['Game Name'])
|
||||
const indexB = orderMap.get(b['Game Name'])
|
||||
|
||||
// Games that are not in the order array should go after those that are in the array.
|
||||
if (indexA !== undefined && indexB !== undefined) {
|
||||
return indexA - indexB
|
||||
} else if (indexA !== undefined) {
|
||||
return -1 // a comes before b
|
||||
} else if (indexB !== undefined) {
|
||||
return 1 // b comes before a
|
||||
} else {
|
||||
return 0 // keep original order if neither is in the array
|
||||
}
|
||||
})
|
||||
}, [games, gamesWithMods])
|
||||
|
||||
// Pagination logic
|
||||
const totalGames = sortedGames.length
|
||||
const totalPages = Math.ceil(totalGames / MAX_GAMES_PER_PAGE)
|
||||
const startIndex = (currentPage - 1) * MAX_GAMES_PER_PAGE
|
||||
const endIndex = startIndex + MAX_GAMES_PER_PAGE
|
||||
const currentGames = sortedGames.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
q: value,
|
||||
kind: 'Games'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "Enter" key press inside the input
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Games</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>
|
||||
<SearchInput
|
||||
ref={searchTermRef}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList IBMSMListFeaturedAlt'>
|
||||
<GameCard backgroundLink='https://m.media-amazon.com/images/M/MV5BZTRiNTgxMjQtNDE3OS00YTg4LTg3NTItY2EyNGUzYjAzZGZmXkEyXkFqcGdeQXVyMTI0MzI0MzE4._V1_FMjpg_UX1000_.jpg' />
|
||||
<GameCard backgroundLink='https://upload.wikimedia.org/wikipedia/en/0/0c/Witcher_3_cover_art.jpg' />
|
||||
<GameCard backgroundLink='https://cdn2.steamgriddb.com/file/sgdb-cdn/grid/9153bb77795515274c2be61ccc59c952.jpg' />
|
||||
<GameCard backgroundLink='https://static.trueachievements.com/boxart/Game_12493.jpg' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
</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>
|
||||
{currentGames.map((game) => (
|
||||
<GameCard
|
||||
key={game['Game Name']}
|
||||
title={game['Game Name']}
|
||||
imageUrl={game['Boxart image']}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaginationWithPageNumbers
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|