Compare commits

...

291 Commits

Author SHA1 Message Date
1e7a89861a Update src/styles/popup.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-02-06 09:58:14 +00:00
0d2589fe6a Update src/styles/comments.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
2025-02-06 09:56:37 +00:00
en
29f02085f5 fix: favicons
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-02-05 09:33:21 +01:00
4d0705a410 Update src/styles/feed.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-02-04 21:41:09 +00:00
1e75efa0d4 Update src/styles/comments.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m9s
2025-02-04 21:36:39 +00:00
5b04429e22 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-02-04 18:19:58 +00:00
eafb20d62a Update src/styles/feed.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
2025-02-04 18:18:03 +00:00
5d0e90bb41 Update src/styles/cardLists.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-02-04 18:13:50 +00:00
79b8b9e825 Update src/styles/feed.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
2025-02-04 18:08:58 +00:00
6ad41dde10 Update src/styles/cardLists.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m13s
2025-02-04 18:07:25 +00:00
215c738884 chore(git): merge pull request #218 from feat/131-feed into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m11s
Reviewed-on: #218
2025-02-04 17:51:58 +00:00
en
33c8af6018 fix(feed): no posts message 2025-02-04 18:50:16 +01:00
en
d1e85dab96 fix(feed): hide Load More if no posts 2025-02-04 18:47:51 +01:00
en
2991dd448c feat(feed): add blogs feed 2025-02-04 18:34:37 +01:00
en
31d9a2b258 feat(feed): mods feed 2025-02-04 17:23:11 +01:00
en
2053e22753 fix(feed): redirect 2025-02-04 14:16:12 +01:00
en
04d2807c23 feat(feed): base layout 2025-02-04 14:00:21 +01:00
en
a8361c2d7e fix(search): sync search states on navigation
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-02-04 13:14:47 +01:00
en
ccc5de0006 fix(search): apply games search autocomplete in mod-submit
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-02-04 11:52:34 +01:00
0f16de1750 chore(git): merge pull request #215 from issues/212-improved-search into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
Reviewed-on: #215
2025-02-04 09:27:09 +00:00
en
d5924ebf4b refactor(search): apply normalization on user, blog and mod search 2025-02-04 10:24:50 +01:00
en
688e9fcf6d fix(games): update search to handle roman numerals first, skip memoize on searchTerm 2025-02-04 10:24:50 +01:00
en
6236d925c5 feat(games): improve search for games 2025-02-04 10:24:50 +01:00
en
4bba7c888e refactor(games): cache games data 2025-02-04 10:24:50 +01:00
7f1cf5299f Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-02-04 07:29:30 +00:00
0514f3c9ab chore(git): merge pull request #214 from issues/208-profile-box-cache into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
Reviewed-on: #214
2025-02-03 16:43:49 +00:00
en
3df32e2226 fix(profile): fetch only from relays on visiting profile 2025-02-03 17:38:11 +01:00
en
4430d1780e fix(profile): fetch parallel on visiting user profile 2025-02-03 17:38:11 +01:00
en
734f8e6d8c fix(profile): fix condition, update created_at in content 2025-02-03 17:38:11 +01:00
en
52edaf5eca fix(profile): on login fetch profile from relays only 2025-02-03 17:38:11 +01:00
en
a5c1f1db74 refactor(useProfile): add opts to hook 2025-02-03 17:38:11 +01:00
en
89f9ca8a2c refactor(ndk): add opts to findMetadata 2025-02-03 17:38:11 +01:00
3a1cc67ae1 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m12s
2025-02-03 12:29:38 +00:00
3e23db2487 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-02-03 12:22:27 +00:00
aef0e3dbd9 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
2025-02-03 12:17:20 +00:00
d077ca8b59 Delete src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m9s
2025-02-03 12:12:51 +00:00
5b44bc4db2 Delete src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
2025-02-03 12:08:27 +00:00
fc7d83fab4 Delete src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m12s
2025-02-03 12:06:11 +00:00
793c1b17d2 Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-02-03 11:44:07 +00:00
2f8c27df52 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-02-03 11:15:20 +00:00
1f0db4fe51 Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
2025-02-03 11:03:30 +00:00
e45a407be0 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m12s
2025-02-03 09:37:04 +00:00
61a2f50989 Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m13s
2025-02-03 09:29:42 +00:00
edcde359ad Update src/assets/games/Games_Steam5.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m17s
2025-02-03 09:22:43 +00:00
e4a7949de5 Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-02-03 09:17:39 +00:00
5691eef2f0 Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m14s
2025-01-31 10:32:22 +00:00
e4be8cad22 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m14s
2025-01-31 10:30:27 +00:00
50cf8e610a Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
2025-01-31 10:28:40 +00:00
e3b4be26ce chore(git): merge pull request #211 from issues/203-nsfw-redirect into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
Reviewed-on: #211
2025-01-30 18:17:05 +00:00
en
0019713bf9 fix(nsfw): show popup if visiting nsfw post, redirect on cancel to home
Closes #203
2025-01-30 19:13:07 +01:00
en
44d7f57f0a fix(nsfw): close will trigger as if clicking no 2025-01-30 19:08:54 +01:00
7331866479 chore(git): merge pull request#210 from issues/140-zap-split-ux into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
Reviewed-on: #210
2025-01-30 17:27:26 +00:00
en
c1b4dac5a3 feat(zaps): show profile image on qr if available 2025-01-30 18:21:55 +01:00
en
9287822c64 fix(zaps): zap split UX
Closes #140
2025-01-30 18:01:37 +01:00
1ef86470fc Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-30 15:47:52 +00:00
903cf30377 Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-30 15:44:00 +00:00
d99f2941cb Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-30 15:36:38 +00:00
88a1bdfdd3 Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-30 14:49:28 +00:00
f51dde697a Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 14:44:55 +00:00
8747633104 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-30 14:32:26 +00:00
c279e9ee87 Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 14:18:32 +00:00
7f3d54f10c Update src/assets/games/Games_Steam5.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-30 14:15:06 +00:00
c217ed15b8 chore(git): merge pull request #209 from fixes/adv-comments-30-1-25 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m9s
Reviewed-on: #209
2025-01-30 13:43:13 +00:00
en
1226c60917 refactor(comments): move style from react to css 2025-01-30 14:42:12 +01:00
en
94eb88bdd3 fix(comments): clear input on publish 2025-01-30 14:28:42 +01:00
en
bf18d61f1f fix(comments): publish and discovery interaction, add discovery to popup 2025-01-30 14:20:44 +01:00
en
a92d1da7ad fix(comments): move depth calc, parent and root fetch to hook, main post leads to root, add small spinners 2025-01-30 11:12:03 +01:00
3804644635 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 09:26:15 +00:00
en
9b9e97b40e fix(comments): comment out repost button 2025-01-30 09:44:17 +01:00
en
b03fa6e55d fix(comments): show new line in content p 2025-01-30 09:40:12 +01:00
en
8430a55beb fix(comments): cache first comments fetch 2025-01-30 09:34:34 +01:00
99c80855d2 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-29 21:43:34 +00:00
b918c875a4 chore(git): merge pull request #207 from feat/130-adv-comments into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
Reviewed-on: #207
2025-01-29 20:53:32 +00:00
cb7c10384a Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-29 20:34:52 +00:00
en
905b3ee5e4 feat(comments): add popup, types, and utils, split components 2025-01-29 21:24:44 +01:00
en
11f4281067 feat(ndk): use ndk nip07 signer 2025-01-29 21:24:44 +01:00
en
97b44a55f2 feat(reply): publish new reply with ndkevent, fetch kind 1 and 1111 2025-01-29 21:24:44 +01:00
612524741b Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m15s
2025-01-29 17:04:19 +00:00
8189149288 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-29 15:41:47 +00:00
f51018befb Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-28 14:01:20 +00:00
e213a61f56 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:53:22 +00:00
0612c24dee Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
2025-01-28 13:46:40 +00:00
df21fa0346 Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:38:51 +00:00
4d240554ce Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:30:17 +00:00
1d5550190b Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m13s
2025-01-28 09:55:15 +00:00
6dafda7071 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-28 01:09:00 +00:00
8dd73919f5 chore(git): merge pull request #206 from issues/205-zap-tipping-broken into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
Reviewed-on: #206
2025-01-27 13:42:28 +00:00
en
670b981b05 fix(zap): add timeout and hide loading when done 2025-01-27 14:24:48 +01:00
en
b41676e4a9 fix(pubkey): handle error thrown by canceling pubkey fetch 2025-01-27 13:20:44 +01:00
87244dda32 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-27 12:03:48 +00:00
79020fbcde Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-27 12:01:16 +00:00
en
02897ea72a fix(wot): site and mine filter condition updated
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-27 12:28:33 +01:00
en
1bac85f6f0 fix(wot): site and mine filter condition updated
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m11s
Fixes #204
2025-01-27 12:23:45 +01:00
en
144c24b254 chore(deps): update ndk version
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-24 11:09:22 +01:00
dbbbba075b Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-23 22:47:03 +00:00
2dbad13c5d Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
2025-01-22 19:47:12 +00:00
4f27b4aafa Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-22 19:45:47 +00:00
15be873136 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-22 19:44:25 +00:00
73e17c09d5 Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m11s
2025-01-22 19:30:28 +00:00
ed348533ad chore(git): merge pull request #202 from extra/112-mods-fields into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Reviewed-on: #202
2025-01-22 16:51:04 +00:00
en
5a6327fb73 feat(mod): add permissions and details
Close #112
2025-01-22 17:47:38 +01:00
99d7dbe89d Update src/components/ModForm.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2025-01-21 20:40:40 +00:00
e11e6f1fb2 Update src/styles/downloads.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2025-01-21 17:48:06 +00:00
83f15b4b69 Update src/pages/mod/index.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2025-01-21 17:45:09 +00:00
enes
787231ce0d fix(download): reset on new fields
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2025-01-21 17:36:50 +01:00
1e9c24e013 chore(git): merge pull request #201 from extra/196-mods-refactor into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
Reviewed-on: #201
2025-01-21 16:26:50 +00:00
enes
d75e3508ea feat(download): add download details popup 2025-01-21 17:19:35 +01:00
enes
4bec281ea0 feat(download): add media url 2025-01-21 16:27:03 +01:00
enes
3f141ed58b feat(download): add title and remove show more links 2025-01-21 15:47:05 +01:00
beed4dabe0 chore(git): merge pull request #200 from extra/188-only-moderated-filter into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
Reviewed-on: #200
2025-01-21 10:55:57 +00:00
enes
e5dd28c23c feat(filter): add only moderation filter to mods
Closes #188
2025-01-21 11:54:23 +01:00
09dda039da Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
2025-01-20 11:50:10 +00:00
f53eeeece1 Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-19 21:08:29 +00:00
0a2e6a327a Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
2025-01-17 21:31:21 +00:00
9d7c57224b Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
2025-01-17 21:03:05 +00:00
096c16f0f6 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-17 20:50:05 +00:00
85116e0e9f Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
2025-01-17 20:38:15 +00:00
enes
d00b142231 fix(download): add checkUrlForFile timeout
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
Fix #164
2025-01-17 12:51:52 +01:00
enes
32f35ebcca revert: scan link early return
Refs: fbde15e075d92bf4070b75f5decc554ca18db02e.
2025-01-17 12:49:34 +01:00
enes
fbde15e075 fix(download): show notice and return earily if missing malware scan linky
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
Fix #164
2025-01-17 12:29:01 +01:00
enes
599c29b4c4 fix(download): warn on same scan and url link
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Fix #164
2025-01-17 10:24:19 +01:00
enes
a9c5c3d18a build(download): remove unused variable
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-16 19:45:02 +01:00
enes
dc96783231 fix(download): remove reachable and fix typo
Some checks failed
Release to Staging / build_and_release (push) Failing after 26s
Fix #164
2025-01-16 19:42:52 +01:00
enes
18a9744f96 fix(download): scan link detection
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Fix #164
2025-01-16 19:19:34 +01:00
02c4cb52b1 chore(git): merge pull request #199 from extra/164-scan-notice into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Reviewed-on: #199
2025-01-16 17:18:04 +00:00
enes
f335640ec5 feat(download): add malware scan notice
Closes #164
2025-01-16 18:16:36 +01:00
595360c88c chore(git): merge pull request #198 from extra/161-download-link-notice into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Reviewed-on: #198
2025-01-16 14:03:32 +00:00
enes
3d7671c303 feat(download): show notice download url leads to another website
Closes #161
2025-01-16 15:01:44 +01:00
e85b33d95d chore(git): merge pull request #197 from extra/154-blocked-warning into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #197
2025-01-16 12:07:46 +00:00
enes
a835996db7 refactor(mutelist): no need to check admin specific list for isBlocked 2025-01-16 13:06:16 +01:00
enes
8c10a467be fix(router): revalited loaders on auth 2025-01-16 12:57:13 +01:00
enes
cdf23c7fac feat(mutelist): add post warnings to blog/mod
+ fix: block/unblock string
2025-01-16 12:56:46 +01:00
enes
177d2fb2ac fix(zap): hide split button if author has no ln address
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Fixes #183
2025-01-16 11:15:17 +01:00
425f4f9cd4 chore(git): merge pull request #195 from issues/190-missing-repost-tags into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Reviewed-on: #195
2025-01-15 19:22:06 +00:00
enes
d079c1a564 fix(home): add repost tag to latest mods 2025-01-15 20:20:07 +01:00
enes
e0394ab0fd feat: add repostList hook 2025-01-15 20:19:02 +01:00
df5ebdb37f chore(git): merge pull request #194 from issues/179-only-logged-in-report into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
Reviewed-on: #194
2025-01-15 18:33:25 +00:00
enes
4832c46548 fix(report): available only to logged in users
Closes #179
2025-01-15 19:32:06 +01:00
a97159119d chore(git): merge pull request #193 from issues/183-ln-button into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Reviewed-on: #193
2025-01-15 18:12:08 +00:00
enes
0f2af47087 fix(profile): check for empty strings for ln
Closes #183
2025-01-15 19:10:48 +01:00
138de5a2ae chore(git): merge pull request #192 from 182-old-mods-edit into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #192
2025-01-15 16:07:43 +00:00
enes
9931f4ec0d feat(mods): editor error handling 2025-01-15 17:05:35 +01:00
enes
094b7349b3 feat(editor): add diffsourcePlugin 2025-01-15 17:05:12 +01:00
enes
3f80f9e0ce refactor: remove unused package 2025-01-15 17:04:09 +01:00
2949444c8a chore(git): merge pull request #191 from 186-try-again-mod-publish into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
Reviewed-on: #191
2025-01-15 12:24:36 +00:00
enes
ce27515bfe feat: spinner with timer 2025-01-14 17:48:15 +01:00
enes
5d479102d4 feat: mod submission try again 2025-01-14 17:23:47 +01:00
enes
a247f05f6e refactor: publish then catch to try catch 2025-01-14 17:16:22 +01:00
enes
dddabbc1d1 feat(errors): timeout error and set prototype 2025-01-09 17:20:59 +01:00
df27451c46 chore(git): merge pull request #187 from 166-caching-fields into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
Reviewed-on: #187
2025-01-09 13:45:45 +00:00
enes
17e9cad9e3 refactor(mods): use lodash clone 2025-01-09 14:30:54 +01:00
enes
5fad718356 feat(cache): clear cache on succesful publish 2025-01-09 14:17:12 +01:00
enes
c95af90b28 fix(mod): set original author field to optional 2025-01-09 14:04:45 +01:00
enes
60773ec446 feat(cache): add blog cache, blog to controlled inputs 2025-01-09 13:21:49 +01:00
enes
30a87cc347 feat(cache): add mod cache 2025-01-09 13:20:44 +01:00
enes
b3ade8e1d2 style(categories): prettier formatting 2025-01-09 13:20:11 +01:00
enes
b60659eebf feat(cache): add simple localcache hook 2025-01-09 13:19:17 +01:00
enes
f214d66799 refactor(storage): util func moved 2025-01-09 13:18:20 +01:00
enes
ed3585f9c8 fix(mod): reset form 2025-01-08 13:59:35 +01:00
a278800025 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2025-01-08 00:37:04 +00:00
2224403742 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-08 00:32:29 +00:00
649adc609b Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2025-01-07 20:18:50 +00:00
397ab457e4 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-07 20:15:09 +00:00
7854b5480c Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-07 20:06:28 +00:00
cda8e1c210 chore(git): merge pull request #185 from fixes-1-7 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Reviewed-on: #185
2025-01-07 20:02:56 +00:00
enes
33b8565051 feat(image): add spinner while uploading 2025-01-07 21:01:11 +01:00
enes
d4d7dde1ab fix(mod): redirect on edit if user is not original author 2025-01-07 20:40:59 +01:00
6170050070 chore(git): merge pull request #184 from 106-direct-image-upload into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #184
2025-01-07 18:33:55 +00:00
enes
8205d4ac3e refactor: remove comment 2025-01-07 19:29:16 +01:00
enes
080b231f5c chore(git): merge staging into 106-direct-image-upload 2025-01-07 19:25:40 +01:00
e
8f97161eb2 chore: code cleanup 2025-01-07 16:39:23 +01:00
e
7244591d34 fix(image): bad image url input field name 2025-01-07 14:31:19 +01:00
enes
0026f4d751 feat(image): multiple files upload 2025-01-07 12:40:27 +01:00
enes
9fd1aca99c feat(image): use image upload field in blog 2025-01-07 10:04:37 +01:00
enes
4c410be9ba feat(image): use image upload field 2025-01-07 09:49:02 +01:00
enes
b33015cbaf feat(image): add direct image upload components 2025-01-07 09:48:30 +01:00
enes
0b2d488bbe feat(image): add controller, media services and error handling 2025-01-07 09:46:28 +01:00
enes
e3aab5a5dc build: bump to es2022 2025-01-07 09:43:46 +01:00
enes
31cd625886 feat(image): add dropzone package 2025-01-07 09:41:20 +01:00
efa16433e8 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-05 20:32:44 +00:00
2dccadd670 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-05 20:26:19 +00:00
331ac285ce Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-05 14:35:16 +00:00
cff9bbd8d0 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2025-01-05 13:16:39 +00:00
4527c1c154 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2025-01-03 16:11:25 +00:00
35176302e5 Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-03 11:26:38 +00:00
08884cc066 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-02 23:25:40 +00:00
50d982a3a8 Update src/components/ModForm.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-02 21:43:09 +00:00
enes
e15307be3b fix(mod): loading mod edge case
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Close #175 - Add timeout to requests and try again button
2025-01-01 16:16:39 +01:00
c6c2013f1e Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2025-01-01 12:19:03 +00:00
aa9f1015e1 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-01 12:17:25 +00:00
a46aaa9e55 Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2024-12-31 15:50:32 +00:00
50f9800935 chore(git): merge pull request #178 from fixes-12-26 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
Reviewed-on: #178
2024-12-27 10:22:58 +00:00
enes
5449591b6e fix: blogs loading for reported nprofile 2024-12-26 17:21:19 +01:00
enes
1c4a9c8586 fix: failing links in footer, backup and supporters pages prep
Partially #142
2024-12-26 17:20:21 +01:00
enes
ad68ba8e84 fix: add fallback for usersPubkey in loaders 2024-12-26 16:46:40 +01:00
enes
ad3d069ad5 refactor: deps cleanup 2024-12-26 16:42:11 +01:00
enes
4bf9787660 refactor: remove user metadata console errors 2024-12-26 16:40:40 +01:00
enes
037b81c49e fix(games): add no games found in search
Closes #141
2024-12-26 16:39:09 +01:00
7a5639b8cf Upload files to "src/assets/img"
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-12-24 19:36:11 +00:00
enes
2440620328 fix(viewer): remove double sanitize, fix yt directive
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2024-12-24 20:26:38 +01:00
73cec02ee5 chore(git): merge pull request #176 from feedback-11-24 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
Reviewed-on: #176
2024-12-24 18:38:20 +00:00
enes
130be2567d fix(editor): feedback updates 2024-12-24 19:36:58 +01:00
fdbb64d360 chore(git): merge pull request #174 from 170-editor-update into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
Reviewed-on: #174
2024-12-24 16:13:48 +00:00
enes
cbd53852a5 fix(viewer): image bug 2024-12-24 17:09:01 +01:00
enes
137cd95c4e fix(viewer): allow iframe, only from custom yt directive 2024-12-24 17:03:18 +01:00
enes
a6ed390fad fix(viewer): yt directive 2024-12-24 16:36:29 +01:00
enes
0760a3e81e refactor(viewer): table styling 2024-12-24 16:33:43 +01:00
enes
026dae65bb refactor(viewer): add yt directive renderer with marked-directive pkg 2024-12-24 16:07:00 +01:00
enes
e40ec6c5aa refactor(editor): update yt delete button 2024-12-24 14:15:36 +01:00
enes
e384bae945 refactor(editor): override image and link dialog 2024-12-24 14:00:04 +01:00
enes
3080b376aa refactor: remove tiptap 2024-12-23 20:24:11 +01:00
enes
1b1aa4289a refactor: add viewer, swap editor, and refactor submitMod page 2024-12-23 20:21:06 +01:00
0b7d88a18c Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-12-22 23:42:08 +00:00
dd081ed1a2 Update src/assets/categories/categories.json
Some checks failed
Release to Staging / build_and_release (push) Failing after 24s
2024-12-22 23:35:30 +00:00
a93c113701 Update src/assets/categories/categories.json
Some checks failed
Release to Staging / build_and_release (push) Failing after 28s
2024-12-22 23:25:09 +00:00
3ee868e613 Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-12-22 23:12:46 +00:00
c448e3be73 Update src/components/Filters/CategoryFilterPopup.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-12-22 22:46:04 +00:00
d823d3f007 Update src/components/Filters/CategoryFilterPopup.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-12-22 22:42:58 +00:00
c973bdd436 adjusted placeholder text
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-12-22 22:35:46 +00:00
777cd2a7c7 adjusted placeholder text
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-12-22 22:33:36 +00:00
enes
70c15dceb0 fix(category): show indeterminate on linked category parents 2024-12-18 12:48:47 +01:00
enes
52f1735d40 fix(ndk): disable debug
debug mode should be only enabled locally or on proper env
2024-12-18 12:48:10 +01:00
enes
b5ba87443c refactor(blog): replace editor 2024-12-18 12:47:09 +01:00
fd9cd80bc1 chore(git): merge pull request #172 from fixes-12-16 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
Reviewed-on: #172
2024-12-16 12:20:51 +00:00
enes
615b39a8d8 fix(mod): add keys to categories list 2024-12-16 13:14:05 +01:00
enes
3f237ab2af refactor(category): linking category update 2024-12-16 13:10:34 +01:00
b53c759251 Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-12-16 12:00:36 +00:00
579063e073 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-12-13 15:35:32 +00:00
c93850d63c Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-12-13 10:42:09 +00:00
3c026bc0a8 Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
2024-12-13 10:34:47 +00:00
fbae220e7d Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-12-13 10:33:12 +00:00
d1cc49bae2 Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-12-13 10:28:06 +00:00
enes
ba60b7f1d4 fix(mod): submit and edit reset bug
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-12-13 10:44:00 +01:00
1076124356 Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-12-13 00:20:14 +00:00
cc788a433b Update src/assets/games/Games_Steam5.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-12-13 00:15:58 +00:00
23e05b30ab Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-12-13 00:12:15 +00:00
e67c8ae440 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-12-13 00:05:32 +00:00
b27711ed6b chore(git): merge pull request #171 from 116-categories into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
Reviewed-on: #171
2024-12-12 16:37:38 +00:00
enes
ff0eb2cddf refactor: remove unused variable 2024-12-12 17:29:48 +01:00
enes
49b3ce4205 fix(category): top level category bug 2024-12-12 17:19:58 +01:00
enes
b71e503c8f feat(blog): clear form changes with confirm alert popup 2024-12-12 16:48:18 +01:00
enes
5877912cea feat(mod): clear form changes with confirm alert popup 2024-12-12 16:19:08 +01:00
enes
96fe99669b feat(categories): autocomplete user and existing categories, new LTags option to add to user's list 2024-12-12 14:20:57 +01:00
enes
bac48a4486 feat(category): indeterminate state, parent marking 2024-12-11 16:26:16 +01:00
enes
b9d6820405 feat(category): check if user defined already exists, remove duplicates 2024-12-11 14:36:07 +01:00
enes
f7f8778707 feat(category): user hierarchy, fix filter 2024-12-11 14:03:33 +01:00
enes
535aabe4a3 build(audit): update packages 2024-12-11 14:02:10 +01:00
enes
127c1fd8a6 fix(category): use hierarchy links, visual indicator for link 2024-12-05 21:03:00 +01:00
enes
cbcb82e779 fix(storage): memoize hook values after JSON parsing 2024-12-05 20:51:02 +01:00
enes
41cfc57cf9 fix(category): open in new tab, require game select 2024-12-05 13:37:30 +01:00
enes
8d9bbbc7a5 feat(category): category filter popup 2024-12-05 13:02:04 +01:00
enes
ecbe839b30 chore(git): merge branch '137-168-alert-popups' into 116-categories 2024-12-04 14:20:03 +01:00
enes
1454929710 feat(category): initial filter prep 2024-12-04 14:17:00 +01:00
enes
836d5b76e1 feat(category): dynamic dropdown item height 2024-12-04 14:17:00 +01:00
enes
4bf84cd9a6 fix(mod): remove debug code 2024-12-04 14:17:00 +01:00
enes
cd5e6dcd8f feat(categories): link c to games and split input on > 2024-12-04 14:17:00 +01:00
enes
cb94f0ced6 fix(search): remove test categoriesh 2024-12-04 14:17:00 +01:00
enes
3b2dce54c5 feat: initial categories 2024-12-04 14:17:00 +01:00
enes
71f934129c refactor: use local storage instead of session for nsfw preference 2024-12-03 17:32:55 +01:00
enes
1ee56ba91a feat: generic alert popup and nsfw popup confirmation 2024-12-03 15:09:40 +01:00
8c6046ac6d Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-12-02 23:27:35 +00:00
6680acee85 Update src/styles/tags.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2024-12-02 14:49:41 +00:00
f5d03263e7 chore(git): merge pull request #162 from 107-143-refactoring-mod-and-repost into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
Reviewed-on: #162
2024-11-28 16:33:14 +00:00
enes
6246dece84 refactor(mods): use loader to preload lists 2024-11-28 17:21:35 +01:00
enes
f61c32c16a fix: comments 2024-11-28 17:13:53 +01:00
enes
b1d578c329 feat: add filtering, split mods and blog filters 2024-11-28 16:47:10 +01:00
enes
376164cbf4 refactor: add repost tag if missing 2024-11-27 19:56:19 +01:00
enes
35cedba3db feat: add repost and original author fields 2024-11-27 17:17:54 +01:00
enes
6c0ac7d59d fix: mod edit route 2024-11-27 14:42:48 +01:00
enes
c55dc03382 refactor: mod page, add generic report popup, repost option 2024-11-27 12:33:09 +01:00
3d5d59a64d Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-11-26 16:01:02 +00:00
c38d14a633 Update src/pages/mod/index.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-11-22 12:08:34 +00:00
38bd029687 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-11-21 21:03:28 +00:00
f29a2634fd Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-11-21 20:58:39 +00:00
61a94e5358 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-11-21 20:51:21 +00:00
f05f0dc1ea new css and adjusted current ones
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
2024-11-21 20:47:13 +00:00
c9ceed6c0f adjustments to modify the look of 'view' button on mod post body (now 'read full')
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-11-21 20:45:27 +00:00
enes
a241f90269 fix(settings): load relays for new npubs
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
Closes #159
2024-11-20 16:14:39 +01:00
enes
a486e5a383 refactor: remove a few console logs
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-11-20 13:54:35 +01:00
enes
8d20678c75 fix(ndk): dont create NDKRelaySet from empty arrays
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-11-20 13:50:49 +01:00
enes
994382f39c fix(wot): add exclude and fix wot dropdown
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-11-20 12:27:41 +01:00
54ab35e78c chore(git): merge pull request #158 from wot-updates-11-19 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
Reviewed-on: #158
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-11-19 16:27:39 +00:00
enes
02a81213a2 refactor(wot): update redux wot level if admin wot npub changes level 2024-11-19 16:23:42 +01:00
enes
8b5b9a6e30 refactor(wot): ignore filter selection based on ruleset 2024-11-19 13:11:50 +01:00
enes
2936d6d53b refactor(wot): add Trust label to wot filter 2024-11-19 13:09:12 +01:00
enes
4b6926b0b9 refactor(wot): single loop only 2024-11-19 13:08:27 +01:00
990f91c0a6 chore(git): merge pull request 'fix(wot): profile pref user wot' (#157) from 156-wot-level into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
Reviewed-on: #157
2024-11-19 09:07:42 +00:00
enes
81d012b0cb refactor(settings): add readOnly to remove warnings for wip checkboxes 2024-11-19 10:01:02 +01:00
1b960e5f02 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
2024-11-19 00:17:39 +00:00
daniyal
4b6db36646 fix: include authors in wotLevel filter
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-11-19 00:57:32 +05:00
s
0b2de940d0 Merge pull request 'fix: improve wot logic' (#155) from wot-fixes into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #155
2024-11-18 18:25:52 +00:00
168 changed files with 49101 additions and 96157 deletions

View File

@ -1,10 +1,10 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
env: { browser: true, es2022: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
@ -12,7 +12,7 @@ module.exports = {
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
{ allowConstantExport: true }
]
}
}

View File

@ -4,6 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Icons -->
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="DEG Mods" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Links and Stylesheets -->
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />

4699
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,16 +11,12 @@
},
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.11.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
"@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",
"axios": "^1.7.9",
"bech32": "2.0.0",
"buffer": "6.0.3",
"date-fns": "3.6.0",
@ -30,6 +26,7 @@
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"marked": "^14.1.3",
"marked-directive": "^1.0.7",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
"papaparse": "5.4.1",
@ -37,13 +34,13 @@
"react": "^18.3.1",
"react-countdown": "2.3.5",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"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",
"swiper": "11.1.11",
"turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

16
public/favicon.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1672)"><rect width="1000" height="1000" fill="#232323"></rect><g transform="matrix(1.0449442115895924,0,0,1.0449442115895924,170.4245956646426,50)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="630.8" height="861.29"><svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630.8 861.29">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
<clipPath id="SvgjsClipPath1672"><rect width="1000" height="1000" x="0" y="0" rx="500" ry="500"></rect></clipPath></defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M237.92,569.84c0-38.78-31.44-70.22-70.22-70.22s-70.22,31.44-70.22,70.22,31.44,70.22,70.22,70.22,70.22-31.44,70.22-70.22Zm-112.92-.38c0-23.58,19.12-42.7,42.7-42.7s42.7,19.11,42.7,42.7-19.12,42.7-42.7,42.7-42.7-19.12-42.7-42.7Z"></path>
<path class="cls-1" d="M462.6,499.61c-38.78,0-70.22,31.44-70.22,70.22s31.44,70.22,70.22,70.22,70.22-31.44,70.22-70.22-31.44-70.22-70.22-70.22Zm0,112.54c-23.58,0-42.7-19.12-42.7-42.7s19.12-42.7,42.7-42.7,42.7,19.11,42.7,42.7-19.12,42.7-42.7,42.7Z"></path>
<path class="cls-1" d="M615.78,435.15s0,0,0,0c0-.02-.01-.03-.02-.05-5.37-19.37-12.13-38.1-20.19-54.96-33.72-79.69-90.11-172.53-147.06-237.09,8.41,329.27-136.89,120.61-99.7-143.05-121.74,100.78-187.28,276.34-187.28,338.12-19.77-25.06-35.72-65.99-22.23-113.79C40.32,305.6-13.81,467.05,3.05,583.09c18.23,156.62,151.26,278.21,312.77,278.21,173.96,0,314.94-141.76,314.94-315.71,.58-33.27-4.61-73.04-14.97-110.43Zm-197.98-36.97c26.61-7.51,51.84,9.36,54.21,17.76,2.37,8.4-16.79,23.02-43.4,30.54-26.61,7.51-49.53,8.82-52.96-3.32s15.53-37.46,42.14-44.97Zm-262.34,17.76c2.37-8.4,27.6-25.28,54.21-17.76,26.61,7.51,45.57,32.83,42.14,44.97s-26.35,10.84-52.96,3.32c-26.61-7.51-45.77-22.14-43.4-30.54Zm-89,153.89c0-1.15,.05-2.28,.09-3.42-6.05-15.16-10.73-38.24-12.04-73.13,11.93,9.76,21.14,16.61,28.32,21.39,18.06-27.73,49.32-46.08,84.88-46.08,55.92,0,101.24,45.33,101.24,101.24s-45.33,101.24-101.24,101.24-101.24-45.33-101.24-101.24Zm267.7,257.85c-71.14,7.8-143.59-29.61-171.19-91.79,54.5-2.37,122.48-8.33,159.76-12.42,11.33-1.24,23.24-2.82,36.04-4.81l35.62,30.45,23.98-41.42c14.25-2.97,29.55-6.36,46.14-10.23-8.02,80.05-59.21,122.42-130.35,130.22Zm229.69-257.85c0,55.92-45.33,101.24-101.24,101.24s-101.24-45.33-101.24-101.24,45.33-101.24,101.24-101.24c35.57,0,66.84,18.36,84.9,46.11,7.18-4.78,16.4-11.63,28.36-21.41-1.31,34.99-6.02,58.1-12.09,73.26,.03,1.09,.08,2.18,.08,3.28Z"></path>
</g>
</g>
</svg></svg></g></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

21
public/site.webmanifest Normal file
View File

@ -0,0 +1,21 @@
{
"name": "DEG Mods",
"short_name": "DEG Mods",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#232323",
"background_color": "#232323",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,11 +1,12 @@
import { RouterProvider } from 'react-router-dom'
import { useEffect } from 'react'
import { routerWithNdkContext } from 'routes'
import { useEffect, useMemo } from 'react'
import { routerWithNdkContext as routerWithState } from 'routes'
import { useNDKContext } from 'hooks'
import './styles/styles.css'
function App() {
const ndkContext = useNDKContext()
const router = useMemo(() => routerWithState(ndkContext), [ndkContext])
useEffect(() => {
// Find the element with id 'root'
@ -24,7 +25,7 @@ function App() {
}
}, [])
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
return <RouterProvider router={router} />
}
export default App

View File

@ -15,9 +15,14 @@ import {
signAndPublish
} from 'utils'
export const blogReportRouteAction =
export const reportRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
// Check which post type is reported
const url = new URL(request.url)
const isModReport = url.pathname.startsWith('/mod/')
const isBlogReport = url.pathname.startsWith('/blog/')
const title = isModReport ? 'Mod' : isBlogReport ? 'Blog' : 'Post'
const requestData = await request.formData()
const { naddr } = params
if (!naddr) {
@ -30,7 +35,12 @@ export const blogReportRouteAction =
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
aTag = `${kind}:${pubkey}:${identifier}`
if (isModReport) {
aTag = identifier
}
} catch (error) {
log(true, LogType.Error, 'Failed to decode naddr')
return false
@ -82,7 +92,7 @@ export const blogReportRouteAction =
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`)
toast.warn(`${title} reference is already in user's mute list`)
return false
}
tags.push(['a', aTag])
@ -109,10 +119,12 @@ export const blogReportRouteAction =
log(
true,
LogType.Error,
'Could not get pubkey for reporting blog!',
`Could not get pubkey for reporting ${title.toLowerCase()}!`,
error
)
toast.error('Could not get pubkey for reporting blog!')
toast.error(
`Could not get pubkey for reporting ${title.toLowerCase()}!`
)
return false
}
@ -122,7 +134,7 @@ export const blogReportRouteAction =
ndkContext.publish
)
return { isSent: isUpdated }
} else {
} else if (reportingPubkey) {
const href = window.location.href
let message = `I'd like to report ${href} due to following reasons:\n`
Object.entries(formSubmit).forEach(([key, value]) => {
@ -133,14 +145,26 @@ export const blogReportRouteAction =
try {
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
reportingPubkey,
ndkContext.ndk,
ndkContext.publish
)
return { isSent: isSent }
} catch (error) {
log(true, LogType.Error, 'Failed to send a blog report', error)
log(
true,
LogType.Error,
`Failed to send a ${title.toLowerCase()} report`,
error
)
return false
}
} else {
log(
true,
LogType.Error,
`Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing`
)
return false
}
}

View File

@ -0,0 +1,19 @@
[
{ "name": "gameplay ", "sub": ["difficulty"] },
{ "name": "input", "sub": ["key mapping", "macro"] },
{
"name": "visual",
"sub": ["textures", "lighting", "character models", "environment models"]
},
{ "name": "audio", "sub": ["sfx", "music", "voice"] },
{ "name": "user interface", "sub": ["hud", "menu"] },
{
"name": "quality of life",
"sub": ["bug fixes", "performance", "accessibility"]
},
"total conversions",
"translation",
"multiplayer",
"clothing",
"mod manager"
]

View File

@ -4,4 +4,12 @@ Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac
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
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
The Elder Scrolls: Skyblivion,,https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
Stellar Blade,,https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
Bayonetta 2,,https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
Grand Theft Auto: Vice City,,https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
Alan Wake 2,,https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
Guitar Hero World Tour,,https://image.nostr.build/7b0567e703e489479646be65ecf8a998edc092ff0e01f8c85d1726b327bc26f8.jpg
1 Game Name 16 by 9 image Boxart image
4 Vintage Story https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
5 Yandere Simulator https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
6 Genshin Impact https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
7 Zenless Zone Zero https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
8 Ananta https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
9 Bloodborne https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
10 The Elder Scrolls: Skyblivion https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
11 Stellar Blade https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
12 Bayonetta 2 https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
13 Grand Theft Auto: Vice City https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
14 Alan Wake 2 https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
15 Guitar Hero World Tour https://image.nostr.build/7b0567e703e489479646be65ecf8a998edc092ff0e01f8c85d1726b327bc26f8.jpg

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,11 @@
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
FINAL FANTASY VII REBIRTH,,https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
NINJA GAIDEN 2 Black,,https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
Rise of the Ronin,,https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
NINJA GAIDEN 4,,https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
Batman Arkham Asylum,,https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
Kingdom Hearts,,https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
Kingdom Hearts II,,https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg
The Killing Antidote,,https://image.nostr.build/4fdb2c26e3830f2a9a5f02d2b09d6ee9bb47135031389022f92f1cc4416ebc0a.jpg
1 Game Name 16 by 9 image Boxart image
2 Marvel's Spider-Man 2 https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
3 S.T.A.L.K.E.R. 2: Heart of Chornobyl https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
4 FINAL FANTASY VII REBIRTH https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
5 NINJA GAIDEN 2 Black https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
6 Rise of the Ronin https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
7 NINJA GAIDEN 4 https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
8 Batman Arkham Asylum https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
9 Kingdom Hearts https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
10 Kingdom Hearts II https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg
11 The Killing Antidote https://image.nostr.build/4fdb2c26e3830f2a9a5f02d2b09d6ee9bb47135031389022f92f1cc4416ebc0a.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -0,0 +1,72 @@
import { createPortal } from 'react-dom'
import { AlertPopupProps } from 'types'
export const AlertPopup = ({
header,
label,
handleConfirm,
handleClose
}: AlertPopupProps) => {
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{header}</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
{label}
</label>
</div>
<div
style={{
display: 'flex',
width: '100%',
gap: '10px'
}}
>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => handleConfirm(true)}
>
Yes
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => handleConfirm(false)}
>
No
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@ -0,0 +1,333 @@
import { useLocalStorage } from 'hooks'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { getGamePageRoute } from 'routes'
import { ModFormState, Categories, Category } from 'types'
import {
getCategories,
flattenCategories,
addToUserCategories,
capitalizeEachWord
} from 'utils'
interface CategoryAutocompleteProps {
game: string
LTags: string[]
setFormState: (value: React.SetStateAction<ModFormState>) => void
}
export const CategoryAutocomplete = ({
game,
LTags,
setFormState
}: CategoryAutocompleteProps) => {
// Fetch the hardcoded categories from assets
const flattenedCategories = useMemo(() => getCategories(), [])
// Fetch the user categories from local storage
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const flattenedUserCategories = useMemo(
() => flattenCategories(userHierarchies, []),
[userHierarchies]
)
// Create options and select categories from the mod LTags (hierarchies)
const { selectedCategories, combinedOptions } = useMemo(() => {
const combinedCategories = [
...flattenedCategories,
...flattenedUserCategories
]
const hierarchies = LTags.map((hierarchy) => {
const existingCategory = combinedCategories.find(
(cat) => cat.hierarchy === hierarchy.replace(/:/g, ' > ')
)
if (existingCategory) {
return existingCategory
} else {
const segments = hierarchy.split(':')
const lastSegment = segments[segments.length - 1]
return { name: lastSegment, hierarchy: hierarchy, l: [lastSegment] }
}
})
// Selected categorires (based on the LTags)
const selectedCategories = Array.from(new Set([...hierarchies]))
// Combine user, predefined category hierarchies and selected values (LTags in case some are missing)
const combinedOptions = Array.from(
new Set([...combinedCategories, ...selectedCategories])
)
return { selectedCategories, combinedOptions }
}, [LTags, flattenedCategories, flattenedUserCategories])
const [inputValue, setInputValue] = useState<string>('')
const filteredOptions = useMemo(
() =>
combinedOptions.filter((option) =>
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
),
[combinedOptions, inputValue]
)
const getSelectedCategories = (cats: Categories[]) => {
const uniqueValues = new Set(
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
)
const concatenatedValue = Array.from(uniqueValues)
return concatenatedValue
}
const getSelectedHierarchy = (cats: Categories[]) => {
const hierarchies = cats.reduce<string[]>(
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
[]
)
const concatenatedValue = Array.from(hierarchies)
return concatenatedValue
}
const handleReset = () => {
setFormState((prevState) => ({
...prevState,
['lTags']: [],
['LTags']: []
}))
setInputValue('')
}
const handleRemove = (option: Categories) => {
const updatedCategories = selectedCategories.filter(
(cat) => cat.hierarchy !== option.hierarchy
)
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
}
const handleSelect = (option: Categories) => {
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
const updatedCategories = [...selectedCategories, option]
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
}
setInputValue('')
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleAddNew = () => {
if (inputValue) {
const value = inputValue.trim().toLowerCase()
const values = value.split('>').map((s) => s.trim())
const newOption: Categories = {
name: value,
hierarchy: value,
l: values
}
setUserHierarchies((prev) => {
addToUserCategories(prev, value)
return [...prev]
})
const updatedCategories = [...selectedCategories, newOption]
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
setInputValue('')
}
}
const handleAddNewCustom = (option: Categories) => {
setUserHierarchies((prev) => {
addToUserCategories(prev, option.hierarchy)
return [...prev]
})
}
const Row = ({ index }: { index: number }) => {
return (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={() => handleSelect(filteredOptions[index])}
>
{capitalizeEachWord(filteredOptions[index].hierarchy)}
{/* Show "Remove" button when the category is selected */}
{selectedCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
type='button'
className='btn btnMain btnMainInsideField btnMainRemove'
onClick={() => handleRemove(filteredOptions[index])}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
</svg>
</button>
)}
{/* Show "Add" button when the category is not included in the predefined or userdefined lists */}
{!flattenedCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) &&
!flattenedUserCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
onClick={() => handleAddNewCustom(filteredOptions[index])}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
)}
</div>
)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Categories</label>
<p className='labelDescriptionMain'>You can select multiple categories</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<input
type='text'
className='inputMain inputMainWithBtn dropdown-toggle'
placeholder='Select some categories...'
data-bs-toggle='dropdown'
value={inputValue}
onChange={handleInputChange}
/>
<button
className='btn btnMain btnMainInsideField btnMainRemove'
title='Remove'
type='button'
onClick={handleReset}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<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 dropdownMainMenuAlt category'
style={{
maxHeight: '500px'
}}
>
{filteredOptions.length > 0 ? (
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
) : (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={handleAddNew}
>
{inputValue &&
!filteredOptions?.find(
(option) =>
option.hierarchy.toLowerCase() === inputValue.toLowerCase()
) ? (
<>
Add "{inputValue}"
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
</>
) : (
<>No matches</>
)}
</div>
)}
</div>
</div>
</div>
{LTags.length > 0 && (
<div className='IBMSMSMBSSCategories'>
{LTags.map((hierarchy) => {
const hierarchicalCategories = hierarchy.split(`:`)
const categories = hierarchicalCategories
.map<React.ReactNode>((c, i) => {
const partialHierarchy = hierarchicalCategories
.slice(0, i + 1)
.join(':')
return game ? (
<Link
key={`category-${i}`}
target='_blank'
to={{
pathname: getGamePageRoute(game),
search: `h=${partialHierarchy}`
}}
className='IBMSMSMBSSCategoriesBoxItem'
>
<p>{capitalizeEachWord(c)}</p>
</Link>
) : (
<p className='IBMSMSMBSSCategoriesBoxItem'>
{capitalizeEachWord(c)}
</p>
)
})
.reduce((prev, curr, i) => [
prev,
<div
key={`separator-${i}`}
className='IBMSMSMBSSCategoriesBoxSeparator'
>
<p>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,113 @@
import { createPortal } from 'react-dom'
import { DownloadUrl } from '../types'
export const DownloadDetailsPopup = ({
title,
url,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote,
mediaUrl,
handleClose
}: DownloadUrl & {
handleClose: () => void
}) => {
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{title || 'Authentication Details'}</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTable'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Download URL</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{url}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>SHA-256 hash</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{hash}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Signature from</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{signatureKey}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Scan</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{malwareScanLink}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Mod Version</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{modVersion}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Note</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{customNote}</p>
</div>
</div>
{typeof mediaUrl !== 'undefined' && mediaUrl !== '' && (
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Media</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<img
src={mediaUrl}
className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol_Img'
alt=''
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@ -0,0 +1,148 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { Filter } from '.'
import { NsfwFilterOptions } from './NsfwFilterOptions'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const BlogsFilter = React.memo(
({ author, filterKey = 'filter-blog' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</Dropdown>
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item) => {
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 (
<Option
key={`sort-${item}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{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 (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
</Filter>
)
}
)

View File

@ -0,0 +1,3 @@
.noResult:not(:only-child) {
display: none;
}

View File

@ -0,0 +1,550 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Category } from 'types'
import {
addToUserCategories,
capitalizeEachWord,
deleteFromUserCategories,
flattenCategories
} from 'utils'
import { useLocalStorage } from 'hooks'
import { useSearchParams } from 'react-router-dom'
import styles from './CategoryFilterPopup.module.scss'
import categoriesData from './../../assets/categories/categories.json'
interface CategoryFilterPopupProps {
categories: string[]
setCategories: React.Dispatch<React.SetStateAction<string[]>>
hierarchies: string[]
setHierarchies: React.Dispatch<React.SetStateAction<string[]>>
handleClose: () => void
}
export const CategoryFilterPopup = ({
categories,
setCategories,
hierarchies,
setHierarchies,
handleClose
}: CategoryFilterPopupProps) => {
const [searchParams, setSearchParams] = useSearchParams()
const linkedHierarchy = searchParams.get('h')
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const [filterCategories, setFilterCategories] = useState(categories)
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
const handleApply = () => {
// Update selection with linked category if it exists
if (linkedHierarchy !== null && linkedHierarchy !== '') {
// Combine existing selection with the linked
setFilterHierarchies((prev) => {
prev.push(linkedHierarchy)
const newFilterHierarchies = Array.from(new Set([...prev]))
setHierarchies(newFilterHierarchies)
return newFilterHierarchies
})
// Clear hierarchy link in search params
searchParams.delete('h')
setSearchParams(searchParams)
} else {
setHierarchies(filterHierarchies)
}
setCategories(filterCategories)
}
const [inputValue, setInputValue] = useState<string>('')
const userHierarchiesMatching = useMemo(
() =>
flattenCategories(userHierarchies, []).some((h) =>
h.hierarchy.includes(inputValue.toLowerCase())
),
[inputValue, userHierarchies]
)
// const hierarchiesMatching = useMemo(
// () =>
// flattenCategories(categoriesData, []).some((h) =>
// h.hierarchy.includes(inputValue.toLowerCase())
// ),
// [inputValue]
// )
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleSingleSelection = (category: string, isSelected: boolean) => {
let updatedCategories = [...filterCategories]
if (isSelected) {
updatedCategories.push(category)
} else {
updatedCategories = updatedCategories.filter((item) => item !== category)
}
setFilterCategories(updatedCategories)
}
const handleCombinationSelection = (path: string[], isSelected: boolean) => {
const pathString = path.join(':')
let updatedHierarchies = [...filterHierarchies]
if (isSelected) {
updatedHierarchies.push(pathString)
} else {
updatedHierarchies = updatedHierarchies.filter(
(item) => item !== pathString
)
}
setFilterHierarchies(updatedHierarchies)
}
const handleAddNew = () => {
if (inputValue) {
const value = inputValue.toLowerCase()
const values = value
.trim()
.split('>')
.map((s) => s.trim())
setUserHierarchies((prev) => {
addToUserCategories(prev, value)
return [...prev]
})
const path = values.join(':')
// Add new hierarchy to current selection and active selection
// Convert through set to remove duplicates
setFilterHierarchies((prev) => {
prev.push(path)
return Array.from(new Set([...prev]))
})
setHierarchies((prev) => {
prev.push(path)
return Array.from(new Set([...prev]))
})
setInputValue('')
}
}
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Categories filter</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Choose categories...
</label>
<p className='labelDescriptionMain'>
Choose one or more pre-definied or custom categories to filter out mods with.
</p>
</div>
<input
type='text'
className='inputMain inputMainWithBtn'
placeholder='Select some categories...'
value={inputValue}
onChange={handleInputChange}
/>
{userHierarchies.length > 0 && (
<>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Custom categories
</label>
<p className='labelDescriptionMain'>Here&apos;s where your custom categories appear (You can add them in the above field. Example &gt; banana &gt; seed)</p>
</div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
{!userHierarchiesMatching && <div>No results.</div>}
{userHierarchies
.filter((c) => typeof c !== 'string')
.map((c, i) => (
<CategoryCheckbox
key={`${c}_${i}`}
inputValue={inputValue}
category={c}
path={[c.name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={
handleCombinationSelection
}
selectedSingles={filterCategories}
selectedCombinations={filterHierarchies}
handleRemove={(path) => {
setUserHierarchies((prev) => {
deleteFromUserCategories(prev, path.join('>'))
return [...prev]
})
// Remove the deleted hierarchies from current filter selection and active selection
setFilterHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
setHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
}}
/>
))}
</div>
</>
)}
<div className='inputLabelWrapperMain'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Categories
</label>
<p className='labelDescriptionMain'>Here&apos;s where you select any of the pre-defined categories</p>
</div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
<div className={`${styles.noResult}`}>
<div>No results.</div>
<br />
{userHierarchiesMatching ? (
<div>Already defined in your categories</div>
) : (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={handleAddNew}
>
Add and search for "{inputValue}" category
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
</div>
)}
</div>
{(categoriesData as Category[]).map((category) => {
const name =
typeof category === 'string' ? category : category.name
return (
<CategoryCheckbox
key={name}
inputValue={inputValue}
category={category}
path={[name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={
handleCombinationSelection
}
selectedSingles={filterCategories}
selectedCombinations={filterHierarchies}
/>
)
})}
</div>
</div>
<div
style={{
display: 'flex',
width: '100%',
gap: '10px'
}}
>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={handleClose}
>
Cancel
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => {
// Clear the linked hierarchy
searchParams.delete('h')
setSearchParams(searchParams)
// Clear current filters
setFilterCategories([])
setFilterHierarchies([])
}}
>
Reset
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => {
handleApply()
handleClose()
}}
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}
interface CategoryCheckboxProps {
inputValue: string
category: Category | string
path: string[]
handleSingleSelection: (category: string, isSelected: boolean) => void
handleCombinationSelection: (path: string[], isSelected: boolean) => void
selectedSingles: string[]
selectedCombinations: string[]
indentLevel?: number
handleRemove?: (path: string[]) => void
}
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
inputValue,
category,
path,
handleSingleSelection,
handleCombinationSelection,
selectedSingles,
selectedCombinations,
indentLevel = 0,
handleRemove
}) => {
const [searchParams, setSearchParams] = useSearchParams()
const linkedHierarchy = searchParams.get('h')
const name = typeof category === 'string' ? category : category.name
const hierarchy = path.join(' > ').toLowerCase()
const isMatching = hierarchy.includes(inputValue.toLowerCase())
const isLinked =
linkedHierarchy !== null &&
hierarchy === linkedHierarchy.replace(/:/g, ' > ')
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
const [isCombinationChecked, setIsCombinationChecked] =
useState<boolean>(false)
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
useEffect(() => {
const pathString = path.join(':')
setIsSingleChecked(selectedSingles.includes(name))
setIsCombinationChecked(selectedCombinations.includes(pathString))
// Recursive function to gather all descendant paths
const collectChildPaths = (
category: string | Category,
basePath: string[]
) => {
if (!category.sub || !Array.isArray(category.sub)) {
return []
}
let paths: string[] = []
for (const sub of category.sub) {
const subPath =
typeof sub === 'string'
? [...basePath, sub].join(':')
: [...basePath, sub.name].join(':')
paths.push(subPath)
if (typeof sub === 'object') {
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
}
}
return paths
}
const childPaths = collectChildPaths(category, path)
const anyChildCombinationSelected = childPaths.some((childPath) =>
selectedCombinations.includes(childPath)
)
const anyChildCombinationLinked = childPaths.some(
(childPath) =>
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
)
setIsIndeterminate(
(anyChildCombinationSelected || anyChildCombinationLinked) &&
!selectedCombinations.includes(pathString)
)
}, [
category,
linkedHierarchy,
name,
path,
selectedCombinations,
selectedSingles
])
const handleSingleChange = () => {
setIsSingleChecked(!isSingleChecked)
handleSingleSelection(name, !isSingleChecked)
}
const handleCombinationChange = () => {
// If combination is linked, clicking it again we will delete it
if (isLinked) {
searchParams.delete('h')
setSearchParams(searchParams)
} else {
setIsCombinationChecked(!isCombinationChecked)
handleCombinationSelection(path, !isCombinationChecked)
}
}
return (
<>
{isMatching && (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
style={{
marginLeft: `${indentLevel * 20}px`,
width: `calc(100% - ${indentLevel * 20}px)`
}}
>
<div
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
style={{
overflow: 'hidden'
}}
>
<input
id={name}
type='checkbox'
ref={(input) => {
if (input) {
input.indeterminate = isIndeterminate
}
}}
className={`CheckboxMain ${
isIndeterminate ? 'CheckboxIndeterminate' : ''
}`}
checked={isCombinationChecked || isLinked}
onChange={handleCombinationChange}
/>
<label
htmlFor={name}
className='form-label labelMain labelMainCategory'
>
{capitalizeEachWord(name)}
</label>
<input
style={{
display: 'none'
}}
id={name}
type='checkbox'
className='CheckboxMain'
name={name}
checked={isSingleChecked}
onChange={handleSingleChange}
/>
{typeof handleRemove === 'function' && (
<button
className='btn btnMain btnMainInsideField btnMainRemove'
title='Remove'
type='button'
onClick={() => handleRemove(path)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
</svg>
</button>
)}
</div>
</div>
)}
{typeof category !== 'string' &&
category.sub &&
Array.isArray(category.sub) && (
<>
{category.sub.map((subCategory) => {
if (typeof subCategory === 'string') {
return (
<CategoryCheckbox
inputValue={inputValue}
key={`${category.name}-${subCategory}`}
category={{ name: subCategory }}
path={[...path, subCategory]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={handleCombinationSelection}
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
handleRemove={handleRemove}
/>
)
} else {
return (
<CategoryCheckbox
inputValue={inputValue}
key={subCategory.name}
category={subCategory}
path={[...path, subCategory.name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={handleCombinationSelection}
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
handleRemove={handleRemove}
/>
)
}
})}
</>
)}
</>
)
}

View File

@ -0,0 +1,25 @@
import { PropsWithChildren } from 'react'
interface DropdownProps {
label: React.ReactNode
}
export const Dropdown = ({
label,
children
}: PropsWithChildren<DropdownProps>) => {
return (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{label}
</button>
<div className='dropdown-menu dropdownMainMenu'>{children}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,83 @@
import React from 'react'
import { PropsWithChildren } from 'react'
import { Filter } from '.'
import { FilterOptions, SortBy } from 'types'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { useLocalStorage } from 'hooks'
import { NsfwFilterOptions } from './NsfwFilterOptions'
interface FeedFilterProps {
tab: number
}
export const FeedFilter = React.memo(
({ tab, children }: PropsWithChildren<FeedFilterProps>) => {
const filterKey = `filter-feed-${tab}`
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
{/* show only if not posts tabs */}
{tab !== 2 && (
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
)}
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
{children}
</Filter>
)
}
)

View File

@ -0,0 +1,176 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React, { PropsWithChildren } from 'react'
import {
FilterOptions,
SortBy,
ModeratedFilter,
WOTFilterOptions,
RepostFilter
} from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Filter } from '.'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { NsfwFilterOptions } from './NsfwFilterOptions'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item, index) => {
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
if (item === ModeratedFilter.Only_Blocked && !isAdmin) {
return null
}
if (item === ModeratedFilter.Unmoderated_Fully) {
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<Option
key={`moderatedFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{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 (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* repost filter options */}
<Dropdown label={filterOptions.repost}>
{Object.values(RepostFilter).map((item, index) => (
<Option
key={`repostFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
repost: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
{children}
</Filter>
)
}
)

View File

@ -0,0 +1,64 @@
import { FilterOptions, NSFWFilter } from 'types'
import { Option } from './Option'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
import { useState } from 'react'
import { useLocalStorage } from 'hooks'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
interface NsfwFilterOptionsProps {
filterKey: string
}
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
const [selectedNsfwOption, setSelectedNsfwOption] = useState<
NSFWFilter | undefined
>()
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const handleConfirm = (confirm: boolean) => {
if (confirm && selectedNsfwOption) {
setFilterOptions((prev) => ({
...prev,
nsfw: selectedNsfwOption
}))
}
}
return (
<>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() => {
// Trigger NSFW popup
if (
(item === NSFWFilter.Only_NSFW ||
item === NSFWFilter.Show_NSFW) &&
!confirmNsfw
) {
setSelectedNsfwOption(item)
setShowNsfwPopup(true)
} else {
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
}}
>
{item}
</Option>
))}
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</>
)
}

View File

@ -0,0 +1,16 @@
import { PropsWithChildren } from 'react'
interface OptionProps {
onClick: React.MouseEventHandler<HTMLDivElement>
}
export const Option = ({
onClick,
children
}: PropsWithChildren<OptionProps>) => {
return (
<div className='dropdown-item dropdownMainMenuItem' onClick={onClick}>
{children}
</div>
)
}

View File

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react'
export const Filter = ({ children }: PropsWithChildren) => {
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>{children}</div>
</div>
)
}

View File

@ -1,377 +0,0 @@
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'
import '../styles/tiptap.scss'
interface InputFieldProps {
label: string
description?: string
type?: 'text' | 'textarea' | 'richtext'
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onChange: (name: string, value: string) => void
}
export const InputField = React.memo(
({
label,
description,
type = 'text',
placeholder,
name,
inputMode,
value,
error,
onChange
}: InputFieldProps) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(name, e.target.value)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
{type === 'textarea' ? (
<textarea
className='inputMain'
placeholder={placeholder}
name={name}
value={value}
onChange={handleChange}
></textarea>
) : type === 'richtext' ? (
<RichTextEditor
content={value}
updateContent={(content) => onChange(name, content)}
/>
) : (
<input
type={type}
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
)}
{error && <InputError message={error} />}
</div>
)
}
)
type InputErrorProps = {
message: string
}
export const InputError = ({ message }: InputErrorProps) => {
if (!message) return null
return (
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
)
}
interface CheckboxFieldProps {
label: string
name: string
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type?: 'default' | 'stylized'
}
export const CheckboxField = React.memo(
({
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}
checked={isChecked}
onChange={handleChange}
/>
</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>
)

View File

@ -0,0 +1,14 @@
type InputErrorProps = {
message: string
}
export const InputError = ({ message }: InputErrorProps) => {
if (!message) return null
return (
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
)
}

View File

@ -0,0 +1,11 @@
.spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
pointer-events: none;
}

View File

@ -0,0 +1,126 @@
import { useDropzone } from 'react-dropzone'
import React, { useCallback, useMemo, useState } from 'react'
import {
MediaOption,
MEDIA_OPTIONS,
ImageController,
MEDIA_DROPZONE_OPTIONS
} from '../../controllers'
import { errorFeedback } from '../../types'
import { MediaInputPopover } from './MediaInputPopover'
import { Spinner } from 'components/Spinner'
import styles from './ImageUpload.module.scss'
export interface ImageUploadProps {
multiple?: boolean | undefined
onChange: (values: string[]) => void
}
export const ImageUpload = React.memo(
({ multiple = false, onChange }: ImageUploadProps) => {
const [isLoading, setIsLoading] = useState(false)
const [mediaOption, setMediaOption] = useState<MediaOption>(
MEDIA_OPTIONS[0]
)
const handleOptionChange = useCallback(
(mo: MediaOption) => () => {
setMediaOption(mo)
},
[]
)
const handleUpload = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length) {
try {
setIsLoading(true)
const imageController = new ImageController(mediaOption)
const urls: string[] = []
for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i]
urls.push(await imageController.post(file))
}
onChange(urls)
} catch (error) {
errorFeedback(error)
} finally {
setIsLoading(false)
}
}
},
[mediaOption, onChange]
)
const {
getRootProps,
getInputProps,
isDragActive,
acceptedFiles,
isFileDialogActive,
isDragAccept,
isDragReject,
fileRejections
} = useDropzone({
...MEDIA_DROPZONE_OPTIONS,
onDrop: handleUpload,
multiple: multiple
})
const dropzoneLabel = useMemo(
() =>
isFileDialogActive
? 'Select files in dialog'
: isDragActive
? isDragAccept
? 'Drop the files here...'
: isDragReject
? 'Drop the files here (one more more unsupported types)...'
: 'Drop the files here...'
: 'Click or drag files here',
[isDragAccept, isDragActive, isDragReject, isFileDialogActive]
)
return (
<div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
<MediaInputPopover
acceptedFiles={acceptedFiles}
fileRejections={fileRejections}
/>
<div className='uploadBoxMainInside' {...getRootProps()} tabIndex={-1}>
<input id='featuredImageUrl-upload' {...getInputProps()} />
<span>{dropzoneLabel}</span>
<div
className='FiltersMainElement'
onClick={(e) => e.stopPropagation()}
>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Image Host: {mediaOption.name}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{MEDIA_OPTIONS.map((mo) => {
return (
<div
key={mo.host}
onClick={handleOptionChange(mo)}
className='dropdown-item dropdownMainMenuItem'
>
{mo.name}
</div>
)
})}
</div>
</div>
</div>
{isLoading && (
<div className={styles.spinner}>
<Spinner />
</div>
)}
</div>
</div>
)
}
)

View File

@ -0,0 +1,14 @@
.accordion-button::after {
position: absolute;
right: 0.75rem;
color: rgba(255, 255, 255, 0.5) !important;
top: unset !important;
bottom: unset !important;
}
.accordion-body > * {
margin-top: 10px;
}
.accordion-item + .accordion-item {
margin-top: 10px;
}

View File

@ -0,0 +1,64 @@
import { FileError } from 'react-dropzone'
import styles from './MediaInputError.module.scss'
type MediaInputErrorProps = {
rootId: string
index: number
message: string
errors?: readonly FileError[] | undefined
}
export const MediaInputError = ({
rootId,
index,
message,
errors
}: MediaInputErrorProps) => {
if (!message) return null
return (
<div className={['accordion-item', styles['accordion-item']].join(' ')}>
<h2 className='accordion-header' role='tab'>
<button
className={[
'accordion-button collapsed',
styles['accordion-button']
].join(' ')}
type='button'
data-bs-toggle='collapse'
data-bs-target={`#${rootId} .item-${index}`}
aria-expanded='false'
aria-controls={`${rootId} .item-${index}`}
>
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
</button>
</h2>
{errors && (
<div
className={`accordion-collapse collapse item-${index}`}
role='tabpanel'
data-bs-parent={`#${rootId}`}
>
<div
className={['accordion-body', styles['accordion-body']].join(' ')}
>
{errors.map((e) => {
return typeof e === 'string' ? (
<div className='errorMain' key={e}>
{e}
</div>
) : (
<div className='errorMain' key={e.code}>
{e.message}
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,45 @@
.popover {
border-radius: 15px;
box-shadow: 0 0 16px 0px rgb(0 0 0 / 15%);
background: #232323;
z-index: 2;
}
.content {
max-height: 500px;
overflow-y: auto;
padding: 25px;
> *:not(:first-child) {
margin-top: 10px;
}
}
.trigger {
position: absolute;
top: 25px;
right: 25px;
color: rgba(255, 255, 255, 0.5);
}
.mediaInputError {
--bs-accordion-color: unset;
--bs-accordion-bg: unset;
--bs-accordion-transition: unset;
--bs-accordion-border-color: unset;
--bs-accordion-border-width: unset;
--bs-accordion-border-radius: unset;
--bs-accordion-inner-border-radius: unset;
--bs-accordion-btn-padding-x: unset;
--bs-accordion-btn-padding-y: unset;
--bs-accordion-btn-color: unset;
--bs-accordion-btn-bg: unset;
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-focus-border-color: unset;
--bs-accordion-btn-focus-box-shadow: unset;
--bs-accordion-body-padding-x: unset;
--bs-accordion-body-padding-y: unset;
--bs-accordion-active-color: unset;
--bs-accordion-active-bg: unset;
}

View File

@ -0,0 +1,108 @@
import * as Popover from '@radix-ui/react-popover'
import { v4 as uuidv4 } from 'uuid'
import { useMemo } from 'react'
import { FileRejection, FileWithPath } from 'react-dropzone'
import { MediaInputError } from './MediaInputError'
import { InputSuccess } from './Success'
import styles from './MediaInputPopover.module.scss'
interface MediaInputPopoverProps {
acceptedFiles: readonly FileWithPath[]
fileRejections: readonly FileRejection[]
}
export const MediaInputPopover = ({
acceptedFiles,
fileRejections
}: MediaInputPopoverProps) => {
const uuid = useMemo(() => uuidv4(), [])
const acceptedFileItems = useMemo(
() =>
acceptedFiles.map((file) => (
<InputSuccess
key={file.path}
message={`${file.path} - ${file.size} bytes`}
/>
)),
[acceptedFiles]
)
const fileRejectionItems = useMemo(() => {
const id = `errors-${uuid}`
return (
<div
className={`accordion accordion-flush ${styles.mediaInputError}`}
role='tablist'
id={id}
>
{fileRejections.map(({ file, errors }, index) => (
<MediaInputError
rootId={id}
index={index}
key={file.path}
message={`${file.path} - ${file.size} bytes`}
errors={errors}
/>
))}
</div>
)
}, [fileRejections, uuid])
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
return (
<Popover.Root>
<Popover.Trigger asChild>
<div className={styles.trigger}>
{acceptedFiles.length > 0 ? (
<svg
width='1.5em'
height='1.5em'
fill='currentColor'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 576 512'
>
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm211.3-43.3c-6.2-6.2-16.4-6.2-22.6 0L416 385.4l-28.7-28.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l40 40c6.2 6.2 16.4 6.2 22.6 0l72-72c6.2-6.2 6.2-16.4 0-22.6z' />
</svg>
) : (
<svg
width='1.5em'
height='1.5em'
fill='tomato'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 576 512'
>
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zm48 96a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm0 240a24 24 0 1 0 0-48 24 24 0 1 0 0 48zm0-192c-8.8 0-16 7.2-16 16l0 80c0 8.8 7.2 16 16 16s16-7.2 16-16l0-80c0-8.8-7.2-16-16-16z' />
</svg>
)}
</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className={styles.popover} sideOffset={5}>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Selected files</h3>
</div>
<Popover.Close asChild aria-label='Close'>
<div className='popUpMainCardTopClose'>
<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>
</Popover.Close>
</div>
<div className={styles.content}>
{acceptedFileItems}
{fileRejectionItems}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}

View File

@ -0,0 +1,14 @@
type InputSuccessProps = {
message: string
}
export const InputSuccess = ({ message }: InputSuccessProps) => {
if (!message) return null
return (
<div className='successMain'>
<div className='successMainColor'></div>
<p className='successMainText'>{message}</p>
</div>
)
}

View File

@ -0,0 +1,206 @@
import React, { useCallback } from 'react'
import { InputError } from './Error'
import { ImageUpload } from './ImageUpload'
import '../../styles/styles.css'
interface InputFieldProps {
label: string | React.ReactElement
description?: string
type?: 'text' | 'textarea'
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onChange: (name: string, value: string) => void
}
export const InputField = React.memo(
({
label,
description,
type = 'text',
placeholder,
name,
inputMode,
value,
error,
onChange
}: InputFieldProps) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(name, e.target.value)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
{type === 'textarea' ? (
<textarea
className='inputMain'
placeholder={placeholder}
name={name}
value={value}
onChange={handleChange}
></textarea>
) : (
<input
type={type}
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
)}
{error && <InputError message={error} />}
</div>
)
}
)
interface CheckboxFieldProps {
label: string
name: string
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type?: 'default' | 'stylized'
}
export const CheckboxField = React.memo(
({
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}
checked={isChecked}
onChange={handleChange}
/>
</div>
)
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string | React.ReactElement
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>
)
interface InputFieldWithImageUploadProps {
label: string | React.ReactElement
description?: string
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onInputChange: (name: string, value: string) => void
}
export const InputFieldWithImageUpload = React.memo(
({
label,
description,
placeholder,
name,
inputMode,
value,
error,
onInputChange
}: InputFieldWithImageUploadProps) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onInputChange(name, e.currentTarget.value)
},
[name, onInputChange]
)
const handleFileChange = useCallback(
(values: string[]) => {
onInputChange(name, values[0])
},
[name, onInputChange]
)
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{typeof description !== 'undefined' && (
<p className='labelDescriptionMain'>{description}</p>
)}
<ImageUpload onChange={handleFileChange} />
<input
type='text'
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
{error && <InputError message={error} />}
</div>
)
}
)

View File

@ -1,3 +1,4 @@
import { Dots } from 'components/Spinner'
import { ZapSplit } from 'components/Zap'
import {
useAppSelector,
@ -8,7 +9,7 @@ import {
import { useState } from 'react'
import { toast } from 'react-toastify'
import { Addressable } from 'types'
import { abbreviateNumber } from 'utils'
import { abbreviateNumber, log, LogType } from 'utils'
type ZapProps = {
addressable: Addressable
@ -16,15 +17,25 @@ type ZapProps = {
export const Zap = ({ addressable }: ZapProps) => {
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isAvailable, setIsAvailable] = useState(false)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const { getTotalZapAmount } = useNDKContext()
const { getTotalZapAmount, findMetadata } = useNDKContext()
useBodyScrollDisable(isOpen)
useDidMount(() => {
findMetadata(addressable.author)
.then((res) => {
setIsAvailable(typeof res?.lud16 !== 'undefined' && res.lud16 !== '')
})
.catch((err) => {
log(true, LogType.Error, err.message || err)
})
getTotalZapAmount(
addressable.author,
addressable.id,
@ -38,8 +49,14 @@ export const Zap = ({ addressable }: ZapProps) => {
.catch((err) => {
toast.error(err.message || err)
})
.finally(() => {
setIsLoading(false)
})
})
// Hide button if the author hasn't set lud16
if (!isAvailable) return null
return (
<>
<div
@ -47,7 +64,7 @@ export const Zap = ({ addressable }: ZapProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
}`}
onClick={() => setIsOpen(true)}
onClick={isLoading ? undefined : () => setIsOpen(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -62,7 +79,7 @@ export const Zap = ({ addressable }: ZapProps) => {
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(totalZappedAmount)}
{isLoading ? <Dots /> : abbreviateNumber(totalZappedAmount)}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>

View File

@ -1,12 +1,12 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useNavigation } from 'react-router-dom'
import styles from '../../styles/loadingSpinner.module.scss'
interface Props {
desc: string
}
export const LoadingSpinner = (props: Props) => {
const { desc } = props
export const LoadingSpinner = ({ desc }: Props) => {
return (
<div className={styles.loadingSpinnerOverlay}>
<div className={styles.loadingSpinnerContainer}>
@ -16,3 +16,62 @@ export const LoadingSpinner = (props: Props) => {
</div>
)
}
export const RouterLoadingSpinner = () => {
const navigation = useNavigation()
if (navigation.state === 'idle') return null
const desc =
navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1)
return <LoadingSpinner desc={`${desc}...`} />
}
interface TimerLoadingSpinner {
timeoutMs?: number
countdownMs?: number
}
export const TimerLoadingSpinner = ({
timeoutMs = 10000,
countdownMs = 30000,
children
}: PropsWithChildren<TimerLoadingSpinner>) => {
const [show, setShow] = useState(false)
const [timer, setTimer] = useState(
Math.floor((countdownMs - timeoutMs) / 1000)
)
const startTime = useMemo(() => Date.now(), [])
useEffect(() => {
let interval: number
const timeout = window.setTimeout(() => {
setShow(true)
interval = window.setInterval(() => {
const time = Date.now() - startTime
const diff = Math.max(0, countdownMs - time)
setTimer(Math.floor(diff / 1000))
}, 1000)
}, timeoutMs)
return () => {
clearTimeout(timeout)
clearInterval(interval)
}
}, [countdownMs, startTime, timeoutMs])
return (
<div className={styles.loadingSpinnerOverlay}>
<div className={styles.loadingSpinnerContainer}>
<div className={styles.loadingSpinner}></div>
{children}
{show && (
<>
<div>You can try again in {timer}s...</div>
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,10 @@
.formAction {
display: flex;
width: 100%;
justify-content: flex-end;
gap: var(--spacing-2);
}
.wrapper {
border-radius: 0;
}

View File

@ -0,0 +1,148 @@
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
codeBlockPlugin,
CodeToggle,
CreateLink,
diffSourcePlugin,
DiffSourceToggleWrapper,
directivesPlugin,
headingsPlugin,
imagePlugin,
InsertCodeBlock,
InsertImage,
InsertTable,
InsertThematicBreak,
linkDialogPlugin,
linkPlugin,
listsPlugin,
ListsToggle,
markdownShortcutPlugin,
MDXEditor,
MDXEditorMethods,
MDXEditorProps,
quotePlugin,
Separator,
StrikeThroughSupSubToggles,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo
} from '@mdxeditor/editor'
import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor'
import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor'
import { YouTubeButton } from './YoutubeButton'
import '@mdxeditor/editor/style.css'
import '../../styles/mdxEditor.scss'
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef
} from 'react'
import { ImageDialog } from './ImageDialog'
import { LinkDialog } from './LinkDialog'
export interface EditorRef {
setMarkdown: (md: string) => void
}
interface EditorProps extends MDXEditorProps {}
/**
* The editor component is small wrapper (`forwardRef`) around {@link MDXEditor MDXEditor} that sets up the toolbars and plugins, and requires `markdown` and `onChange`.
* To reset editor markdown it's required to pass the {@link EditorRef EditorRef}.
*
* Extends {@link MDXEditorProps MDXEditorProps}
*
* **Important**: the markdown is not a state, but an _initialState_ and is not "controlled".
* All updates are handled with onChange and will not be reflected on markdown prop.
* This component should never re-render if used correctly.
* @see https://mdxeditor.dev/editor/docs/getting-started#basic-usage
*/
export const Editor = React.memo(
forwardRef<EditorRef, EditorProps>(({ markdown, onChange, ...rest }, ref) => {
const editorRef = useRef<MDXEditorMethods>(null)
const setMarkdown = useCallback((md: string) => {
editorRef.current?.setMarkdown(md)
}, [])
useImperativeHandle(ref, () => ({ setMarkdown }))
const plugins = useMemo(
() => [
toolbarPlugin({
toolbarContents: () => (
<DiffSourceToggleWrapper
children={
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<CreateLink />
<InsertImage />
<YouTubeButton />
<Separator />
<InsertTable />
<InsertThematicBreak />
<Separator />
<InsertCodeBlock />
</>
}
/>
)
}),
headingsPlugin(),
diffSourcePlugin({
viewMode: 'rich-text',
diffMarkdown: markdown
}),
quotePlugin(),
imagePlugin({
ImageDialog: ImageDialog
}),
tablePlugin(),
linkPlugin(),
linkDialogPlugin({
LinkDialog: LinkDialog
}),
listsPlugin(),
thematicBreakPlugin(),
directivesPlugin({
directiveDescriptors: [YoutubeDirectiveDescriptor]
}),
markdownShortcutPlugin(),
// HACK: due to a bug with shortcut interaction shortcut for code block is disabled
// Editor freezes if you type in ```word and put a space in between ``` word
codeBlockPlugin({
defaultCodeBlockLanguage: '',
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
return (
<MDXEditor
ref={editorRef}
contentEditableClassName='editor'
className='dark-theme dark-editor'
markdown={markdown}
plugins={plugins}
onChange={onChange}
{...rest}
/>
)
}),
() => true
)

View File

@ -0,0 +1,166 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useCellValues, usePublisher } from '@mdxeditor/gurx'
import {
closeImageDialog$,
editorRootElementRef$,
imageDialogState$,
imageUploadHandler$,
saveImage$
} from '@mdxeditor/editor'
import styles from './Dialog.module.scss'
import { createPortal } from 'react-dom'
interface ImageFormFields {
src: string
title: string
altText: string
file: FileList
}
export const ImageDialog: React.FC = () => {
const [state, editorRootElementRef, imageUploadHandler] = useCellValues(
imageDialogState$,
editorRootElementRef$,
imageUploadHandler$
)
const saveImage = usePublisher(saveImage$)
const closeImageDialog = usePublisher(closeImageDialog$)
const { register, handleSubmit, setValue, reset } = useForm<ImageFormFields>({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
values: state.type === 'editing' ? (state.initialValues as any) : {}
})
const [open, setOpen] = useState(state.type !== 'inactive')
useEffect(() => {
setOpen(state.type !== 'inactive')
}, [state.type])
useEffect(() => {
if (!open) {
closeImageDialog()
reset({ src: '', title: '', altText: '' })
}
}, [closeImageDialog, open, reset])
const handleClose = useCallback(() => {
setOpen(false)
}, [])
if (!open) return null
if (!editorRootElementRef?.current) return null
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Add an image</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'>
<form
className='pUMCB_ZapsInside'
onSubmit={(e) => {
void handleSubmit(saveImage)(e)
reset({ src: '', title: '', altText: '' })
e.preventDefault()
e.stopPropagation()
}}
>
{imageUploadHandler === null ? (
<input type='hidden' accept='image/*' {...register('file')} />
) : (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='file'>
Upload an image from your device:
</label>
<input type='file' accept='image/*' {...register('file')} />
</div>
)}
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='src'>
{imageUploadHandler !== null
? 'Or add an image from an URL:'
: 'Add an image from an URL:'}
</label>
<input
defaultValue={
state.type === 'editing'
? state.initialValues.src ?? ''
: ''
}
className='inputMain'
size={40}
autoFocus
{...register('src')}
onChange={(e) => setValue('src', e.currentTarget.value)}
placeholder={'Paste an image src'}
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='alt'>
Alt:
</label>
<input
type='text'
{...register('altText')}
className='inputMain'
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='title'>
Title:
</label>
<input
type='text'
{...register('title')}
className='inputMain'
/>
</div>
<div className={styles.formAction}>
<button
type='submit'
title={'Save'}
aria-label={'Save'}
className='btn btnMain btnMainPopup'
>
Save
</button>
<button
type='reset'
title={'Cancel'}
aria-label={'Cancel'}
className='btn btnMain btnMainPopup'
onClick={handleClose}
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>,
editorRootElementRef?.current
)
}

View File

@ -0,0 +1,306 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import * as Popover from '@radix-ui/react-popover'
import * as Tooltip from '@radix-ui/react-tooltip'
import React from 'react'
import {
activeEditor$,
editorRootElementRef$,
iconComponentFor$,
cancelLinkEdit$,
linkDialogState$,
onWindowChange$,
removeLink$,
switchFromPreviewToLinkEdit$,
updateLink$,
ClickLinkCallback
} from '@mdxeditor/editor'
import { useForm } from 'react-hook-form'
import { Cell, useCellValues, usePublisher } from '@mdxeditor/gurx'
import styles from './Dialog.module.scss'
interface LinkEditFormProps {
url: string
title: string
onSubmit: (link: { url: string; title: string }) => void
onCancel: () => void
}
interface LinkFormFields {
url: string
title: string
}
export function LinkEditForm({
url,
title,
onSubmit,
onCancel
}: LinkEditFormProps) {
const { register, handleSubmit, setValue } = useForm<LinkFormFields>({
values: {
url,
title
}
})
return (
<div className='pUMCB_Zaps'>
<form
className='pUMCB_ZapsInside'
onSubmit={(e) => {
void handleSubmit(onSubmit)(e)
e.stopPropagation()
e.preventDefault()
}}
onReset={(e) => {
e.stopPropagation()
onCancel()
}}
>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='file'>
URL:
</label>
<input
defaultValue={url}
className='inputMain'
size={40}
autoFocus
{...register('url')}
onChange={(e) => setValue('url', e.currentTarget.value)}
placeholder={'Paste an URL'}
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='link-title'>
Title:
</label>
<input
id='link-title'
className='inputMain'
size={40}
{...register('title')}
/>
</div>
<div className={styles.formAction}>
<button
type='submit'
title={'Set URL'}
aria-label={'Set URL'}
className='btn btnMain btnMainPopup'
>
Save
</button>
<button
type='reset'
title={'Cancel change'}
aria-label={'Cancel change'}
className='btn btnMain btnMainPopup'
>
Cancel
</button>
</div>
</form>
</div>
)
}
export const onClickLinkCallback$ = Cell<ClickLinkCallback | null>(null)
/** @internal */
export const LinkDialog = () => {
const [
editorRootElementRef,
activeEditor,
iconComponentFor,
linkDialogState,
onClickLinkCallback
] = useCellValues(
editorRootElementRef$,
activeEditor$,
iconComponentFor$,
linkDialogState$,
onClickLinkCallback$
)
const publishWindowChange = usePublisher(onWindowChange$)
const updateLink = usePublisher(updateLink$)
const cancelLinkEdit = usePublisher(cancelLinkEdit$)
const switchFromPreviewToLinkEdit = usePublisher(switchFromPreviewToLinkEdit$)
const removeLink = usePublisher(removeLink$)
React.useEffect(() => {
const update = () => {
activeEditor?.getEditorState().read(() => {
publishWindowChange(true)
})
}
window.addEventListener('resize', update)
window.addEventListener('scroll', update)
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update)
}
}, [activeEditor, publishWindowChange])
const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false)
const theRect = linkDialogState.rectangle
const urlIsExternal =
linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http')
return (
<Popover.Root open={linkDialogState.type !== 'inactive'}>
<Popover.Anchor
data-visible={linkDialogState.type === 'edit'}
style={{
position: 'fixed',
top: `${theRect?.top ?? 0}px`,
left: `${theRect?.left ?? 0}px`,
width: `${theRect?.width ?? 0}px`,
height: `${theRect?.height ?? 0}px`
}}
/>
<Popover.Portal container={editorRootElementRef?.current}>
<Popover.Content
sideOffset={5}
onOpenAutoFocus={(e) => {
e.preventDefault()
}}
key={linkDialogState.linkNodeKey}
className={[
'popUpMainCard',
...(linkDialogState.type === 'edit' ? [styles.wrapper] : [])
].join(' ')}
>
{linkDialogState.type === 'edit' && (
<LinkEditForm
url={linkDialogState.url}
title={linkDialogState.title}
onSubmit={updateLink}
onCancel={cancelLinkEdit.bind(null)}
/>
)}
{linkDialogState.type === 'preview' && (
<>
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
<p className='IBMSMSMSSS_Author_Top_Address'>
<a
className={styles.linkDialogPreviewAnchor}
href={linkDialogState.url}
{...(urlIsExternal
? { target: '_blank', rel: 'noreferrer' }
: {})}
onClick={(e) => {
if (
onClickLinkCallback !== null &&
typeof onClickLinkCallback === 'function'
) {
e.preventDefault()
onClickLinkCallback(linkDialogState.url)
}
}}
title={
urlIsExternal
? `Open ${linkDialogState.url} in new window`
: linkDialogState.url
}
>
<span>{linkDialogState.url}</span>
{urlIsExternal && iconComponentFor('open_in_new')}
</a>
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
switchFromPreviewToLinkEdit()
}}
>
<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>
</div>
<Tooltip.Provider>
<Tooltip.Root open={copyUrlTooltipOpen}>
<Tooltip.Trigger asChild>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
void window.navigator.clipboard
.writeText(linkDialogState.url)
.then(() => {
setCopyUrlTooltipOpen(true)
setTimeout(() => {
setCopyUrlTooltipOpen(false)
}, 1000)
})
}}
>
<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>
</Tooltip.Trigger>
<Tooltip.Portal container={editorRootElementRef?.current}>
<Tooltip.Content sideOffset={5}>
{'Copied!'}
<Tooltip.Arrow />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
removeLink()
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 640 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z' />
</svg>
</div>
</div>
</div>
</>
)}
<Popover.Arrow />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}

View File

@ -0,0 +1,65 @@
import {
CodeBlockEditorDescriptor,
useCodeBlockEditorContext
} from '@mdxeditor/editor'
import { useCallback, useEffect, useRef } from 'react'
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
match: (_language, _meta) => true,
priority: 0,
Editor: ({ code, focusEmitter }) => {
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
const defaultValue = useRef(code)
const codeRef = useRef<HTMLElement>(null)
const handleInput = useCallback(
(e: React.FormEvent<HTMLElement>) => {
setCode(e.currentTarget.innerHTML)
},
[setCode]
)
useEffect(() => {
const handleFocus = () => {
if (codeRef.current) {
codeRef.current.focus()
}
}
focusEmitter.subscribe(handleFocus)
}, [focusEmitter])
useEffect(() => {
const currentRef = codeRef.current
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Backspace' || event.key === 'Delete') {
if (codeRef.current?.textContent === '') {
parentEditor.update(() => {
lexicalNode.selectNext()
lexicalNode.remove()
})
}
}
}
if (currentRef) {
currentRef.addEventListener('keydown', handleKeyDown)
}
return () => {
if (currentRef) {
currentRef.removeEventListener('keydown', handleKeyDown)
}
}
}, [lexicalNode, parentEditor])
return (
<pre>
<code
ref={codeRef}
contentEditable={true}
onInput={handleInput}
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
/>
</pre>
)
}
}

View File

@ -0,0 +1,37 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { createDirectives, presetDirectiveConfigs } from 'marked-directive'
import { youtubeDirective } from './YoutubeDirective'
import { useMemo } from 'react'
interface ViewerProps {
markdown: string
}
export const Viewer = ({ markdown }: ViewerProps) => {
const html = useMemo(() => {
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
if (node.nodeName && node.nodeName === 'IFRAME') {
const src = node.attributes.getNamedItem('src')
if (!(src && src.value.startsWith('https://www.youtube.com/embed/'))) {
node.remove()
}
}
})
return DOMPurify.sanitize(
marked
.use(createDirectives([...presetDirectiveConfigs, youtubeDirective]))
.parse(`${markdown}`, {
async: false
}),
{
ADD_TAGS: ['iframe']
}
)
}, [markdown])
return (
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
)
}

View File

@ -0,0 +1,36 @@
import { LeafDirective } from 'mdast-util-directive'
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
function getId(url: string) {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
const match = url.match(regExp)
return match && match[7].length == 11 ? match[7] : false
}
export const YouTubeButton = () => {
const insertDirective = usePublisher(insertDirective$)
return (
<DialogButton
tooltipTitle='Insert Youtube video'
submitButtonTitle='Insert video'
dialogInputPlaceholder='Paste the youtube video URL'
buttonContent='YT'
onSubmit={(url) => {
const videoId = getId(url)
if (videoId) {
insertDirective({
name: 'youtube',
type: 'leafDirective',
attributes: { id: videoId },
children: []
} as LeafDirective)
} else {
alert('Invalid YouTube URL')
}
}}
/>
)
}

View File

@ -0,0 +1,37 @@
import { type DirectiveConfig } from 'marked-directive'
// defines `:youtube` directive
export const youtubeDirective: DirectiveConfig = {
level: 'block',
marker: '::',
renderer(token) {
//https://www.youtube.com/embed/<VIDEO_ID>
//::youtube{#<VIDEO_ID>}
let vid: string = ''
if (token.attrs && token.meta.name === 'youtube') {
if (token.attrs.id) {
vid = token.attrs.id as string // Get the video `id` attribute (common id style)
} else if (token.attrs.vid) {
vid = token.attrs.vid as string // Check for the `vid` attribute (youtube directive attribute style)
} else {
// Fallback for id
// In case that video starts with the number it will not be recongizned as an id
// We have to manually fetch it
for (const attr in token.attrs) {
if (
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
attr.startsWith('#')
) {
vid = attr.replace('#', '')
}
}
}
}
if (vid) {
return `<iframe title="Video embed" width="560" height="315" src="https://www.youtube.com/embed/${vid}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
}
return false
}
}

View File

@ -0,0 +1,59 @@
import { LeafDirective } from 'mdast-util-directive'
import { DirectiveDescriptor } from '@mdxeditor/editor'
interface YoutubeDirectiveNode extends LeafDirective {
name: 'youtube'
attributes: { id: string }
}
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
{
name: 'youtube',
type: 'leafDirective',
testNode(node) {
return node.name === 'youtube'
},
attributes: ['id'],
hasChildren: false,
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<button
type='button'
title='delete'
className='btnMain'
onClick={() => {
parentEditor.update(() => {
lexicalNode.selectNext()
lexicalNode.remove()
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 448 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z' />
</svg>
</button>
<iframe
width='560'
height='315'
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
title='YouTube video player'
frameBorder='0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
/>
</div>
)
}
}

View File

@ -57,6 +57,11 @@ export const ModCard = React.memo((props: ModDetails) => {
<p>NSFW</p>
</div>
)}
{props.repost && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagRepost IBMSMSMBSSTagsTagRepostCard'>
<p>REPOST</p>
</div>
)}
</div>
<div className='cMMBody'>
<h3 className='cMMBodyTitle'>{props.title}</h3>

View File

@ -1,5 +1,4 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
Fragment,
useCallback,
@ -8,74 +7,91 @@ 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 { T_TAG_VALUE } from '../constants'
import { useAppSelector, useGames, useNDKContext } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import {
useActionData,
useLoaderData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { FixedSizeList } from 'react-window'
import { useGames } from '../hooks'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import {
DownloadUrl,
ModFormState,
ModPageLoaderResult,
ModPermissions,
MODPERMISSIONS_CONF,
MODPERMISSIONS_DESC,
SubmitModActionResult
} from '../types'
import {
initializeFormState,
isReachable,
isValidImageUrl,
isValidUrl,
log,
LogType,
now
memoizedNormalizeSearchString,
MOD_DRAFT_CACHE_KEY,
normalizeSearchString
} from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner'
import { NDKEvent } from '@nostr-dev-kit/ndk'
interface FormErrors {
game?: string
title?: string
body?: string
featuredImageUrl?: string
summary?: string
nsfw?: string
screenshotsUrls?: string[]
tags?: string
downloadUrls?: string[]
}
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
import { OriginalAuthor } from './OriginalAuthor'
import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor'
import { MEDIA_OPTIONS } from 'controllers'
import { InputError } from './Inputs/Error'
import { ImageUpload } from './Inputs/ImageUpload'
import { useLocalCache } from 'hooks/useLocalCache'
import { toast } from 'react-toastify'
interface GameOption {
value: string
label: string
}
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()
export const ModForm = () => {
const data = useLoaderData() as ModPageLoaderResult
const mod = data?.mod
const actionData = useActionData() as SubmitModActionResult
const formErrors = useMemo(
() => (actionData?.type === 'validation' ? actionData.error : undefined),
[actionData]
)
const [formErrors, setFormErrors] = useState<FormErrors>({})
const navigation = useNavigation()
const submit = useSubmit()
const games = useGames()
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
// Enable cache for the new mod
const isEditing = typeof mod !== 'undefined'
const [cache, setCache, clearCache] =
useLocalCache<ModFormState>(MOD_DRAFT_CACHE_KEY)
const [formState, setFormState] = useState<ModFormState>(
isEditing ? initializeFormState(mod) : cache ? cache : initializeFormState()
)
// Enable backwards compatibility with the mods that used html
const body = useMemo(() => {
// Replace the most problematic HTML tags (<br>)
const fixed = formState.body.replaceAll(/<br>/g, '\r\n')
return fixed
}, [formState.body])
useEffect(() => {
if (location.pathname === appRoutes.submitMod) {
setFormState(initializeFormState())
}
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
if (!isEditing) {
const newCache = _.cloneDeep(formState)
useEffect(() => {
if (existingModData) {
setFormState(initializeFormState(existingModData))
// Remove aTag, dTag and published_at from cache
// These are used for editing and try again timeout
newCache.aTag = ''
newCache.dTag = ''
newCache.published_at = 0
setCache(newCache)
}
}, [existingModData])
}, [formState, isEditing, setCache])
const editorRef = useRef<EditorRef>(null)
useEffect(() => {
const options = games.map((game) => ({
@ -103,6 +119,13 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
[]
)
const handleRadioChange = useCallback((name: string, value: boolean) => {
setFormState((prevState) => ({
...prevState,
[name]: value
}))
}, [])
const addScreenshotUrl = useCallback(() => {
setFormState((prevState) => ({
...prevState,
@ -170,184 +193,85 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
},
[]
)
const handlePublish = async () => {
setIsPublishing(true)
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
useEffect(() => {
const isTimeout = actionData?.type === 'timeout'
setShowTryAgainPopup(isTimeout)
if (isTimeout) {
setFormState((prev) => ({
...prev,
aTag: actionData.data.aTag,
dTag: actionData.data.dTag,
published_at: actionData.data.published_at
}))
}
}, [actionData])
const handleTryAgainConfirm = useCallback(
(confirm: boolean) => {
setShowTryAgainPopup(false)
if (!hexPubkey) {
toast.error('Could not get pubkey')
return
}
if (!(await validateState())) {
setIsPublishing(false)
return
}
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', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
existingModData
? existingModData.published_at.toString()
: currentTimeStamp.toString()
],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
}
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
// Cancel if not confirmed
if (!confirm) return
submit(JSON.stringify(formState), {
method: isEditing ? 'put' : 'post',
encType: 'application/json'
})
},
[formState, isEditing, submit]
)
if (!signedEvent) {
setIsPublishing(false)
return
}
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = useCallback(() => {
setShowConfirmPopup(true)
}, [])
const handleResetConfirm = useCallback(
(confirm: boolean) => {
setShowConfirmPopup(false)
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
// Cancel if not confirmed
if (!confirm) return
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
// Reset fields to the initial or original existing data
const initialState = initializeFormState(mod)
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
// Reset editor
editorRef.current?.setMarkdown(initialState.body)
setFormState(initialState)
// Clear cache
!isEditing && clearCache()
},
[clearCache, isEditing, mod]
)
const handlePublish = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
submit(JSON.stringify(formState), {
method: isEditing ? 'put' : 'post',
encType: 'application/json'
})
},
[formState, isEditing, submit]
)
navigate(getModPageRoute(naddr))
}
setIsPublishing(false)
}
const validateState = async (): Promise<boolean> => {
const errors: FormErrors = {}
if (formState.game === '') {
errors.game = 'Game field can not be empty'
}
if (formState.title === '') {
errors.title = 'Title field can not be empty'
}
if (formState.body === '') {
errors.body = 'Body field can not be empty'
}
if (formState.featuredImageUrl === '') {
errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty'
} else if (
!isValidImageUrl(formState.featuredImageUrl) ||
!(await isReachable(formState.featuredImageUrl))
) {
errors.featuredImageUrl =
'FeaturedImageUrl must be a valid and reachable image URL'
}
if (formState.summary === '') {
errors.summary = 'Summary field can not be empty'
}
if (formState.screenshotsUrls.length === 0) {
errors.screenshotsUrls = ['Required at least one screenshot url']
} else {
for (let i = 0; i < formState.screenshotsUrls.length; i++) {
const url = formState.screenshotsUrls[i]
if (
!isValidUrl(url) ||
!isValidImageUrl(url) ||
!(await isReachable(url))
) {
if (!errors.screenshotsUrls)
errors.screenshotsUrls = Array(formState.screenshotsUrls.length)
errors.screenshotsUrls![i] =
'All screenshot URLs must be valid and reachable image URLs'
}
const extraBoxRef = useRef<HTMLDivElement>(null)
const handleExtraBoxButtonClick = () => {
if (extraBoxRef.current) {
if (extraBoxRef.current.style.display === '') {
extraBoxRef.current.style.display = 'none'
} else {
extraBoxRef.current.style.display = ''
}
}
if (formState.tags === '') {
errors.tags = 'Tags field can not be empty'
}
if (formState.downloadUrls.length === 0) {
errors.downloadUrls = ['Required at least one download url']
} else {
for (let i = 0; i < formState.downloadUrls.length; i++) {
const downloadUrl = formState.downloadUrls[i]
if (!isValidUrl(downloadUrl.url)) {
if (!errors.downloadUrls)
errors.downloadUrls = Array(formState.downloadUrls.length)
errors.downloadUrls![i] = 'Download url must be valid and reachable'
}
}
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
return (
<>
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />}
<form className='IBMSMSMBS_Write' onSubmit={handlePublish}>
<GameDropdown
options={gameOptions}
selected={formState.game}
error={formErrors.game}
selected={formState?.game}
error={formErrors?.game}
onChange={handleInputChange}
/>
@ -356,30 +280,46 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Return the banana mod'
name='title'
value={formState.title}
error={formErrors.title}
error={formErrors?.title}
onChange={handleInputChange}
/>
<InputField
label='Body'
type='richtext'
placeholder="Here's what this mod is all about"
name='body'
value={formState.body}
error={formErrors.body}
onChange={handleInputChange}
/>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Body</label>
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={body}
placeholder="Here's what this mod is all about"
onChange={(md) => {
handleInputChange('body', md)
}}
onError={(payload) => {
toast.error('Markdown error. Fix manually in the source mode.')
log(true, LogType.Error, payload.error)
}}
/>
</div>
{typeof formErrors?.body !== 'undefined' && (
<InputError message={formErrors?.body} />
)}
<input
name='body'
hidden
value={encodeURIComponent(formState?.body)}
readOnly
/>
</div>
<InputField
<InputFieldWithImageUpload
label='Featured Image URL'
description='We recommend to upload images to https://nostr.build/'
type='text'
description={`We recommend to upload images to ${MEDIA_OPTIONS[0].host}`}
inputMode='url'
placeholder='Image URL'
name='featuredImageUrl'
value={formState.featuredImageUrl}
error={formErrors.featuredImageUrl}
onChange={handleInputChange}
error={formErrors?.featuredImageUrl}
onInputChange={handleInputChange}
/>
<InputField
label='Summary'
@ -387,7 +327,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='This is a quick description of my mod'
name='summary'
value={formState.summary}
error={formErrors.summary}
error={formErrors?.summary}
onChange={handleInputChange}
/>
<CheckboxField
@ -397,6 +337,31 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
handleChange={handleCheckboxChange}
type='stylized'
/>
<CheckboxField
label='This is a repost of a mod I did not create'
name='repost'
isChecked={formState.repost}
handleChange={handleCheckboxChange}
type='stylized'
/>
{formState.repost && (
<>
<InputField
label={
<span>
Created by:{' '}
{<OriginalAuthor value={formState.originalAuthor || ''} />}
</span>
}
type='text'
placeholder="Original author's name, npub or nprofile"
name='originalAuthor'
value={formState.originalAuthor || ''}
error={formErrors?.originalAuthor}
onChange={handleInputChange}
/>
</>
)}
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Screenshots URLs</label>
@ -404,6 +369,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addScreenshotUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -417,8 +383,24 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
</button>
</div>
<p className='labelDescriptionMain'>
We recommend to upload images to https://nostr.build/
We recommend to upload images to {MEDIA_OPTIONS[0].host}
</p>
<ImageUpload
multiple={true}
onChange={(values) => {
setFormState((prevState) => ({
...prevState,
screenshotsUrls: Array.from(
new Set([
...prevState.screenshotsUrls.filter((url) => url),
...values
])
)
}))
}}
/>
{formState.screenshotsUrls.map((url, index) => (
<Fragment key={`screenShot-${index}`}>
<ScreenshotUrlFields
@ -427,16 +409,16 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
onUrlChange={handleScreenshotUrlChange}
onRemove={removeScreenshotUrl}
/>
{formErrors.screenshotsUrls &&
formErrors.screenshotsUrls[index] && (
<InputError message={formErrors.screenshotsUrls[index]} />
{formErrors?.screenshotsUrls &&
formErrors?.screenshotsUrls[index] && (
<InputError message={formErrors?.screenshotsUrls[index]} />
)}
</Fragment>
))}
{formState.screenshotsUrls.length === 0 &&
formErrors.screenshotsUrls &&
formErrors.screenshotsUrls[0] && (
<InputError message={formErrors.screenshotsUrls[0]} />
formErrors?.screenshotsUrls &&
formErrors?.screenshotsUrls[0] && (
<InputError message={formErrors?.screenshotsUrls[0]} />
)}
</div>
<InputField
@ -445,9 +427,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Tags'
name='tags'
value={formState.tags}
error={formErrors.tags}
error={formErrors?.tags}
onChange={handleInputChange}
/>
<CategoryAutocomplete
game={formState.game}
LTags={formState.LTags}
setFormState={setFormState}
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Download URLs</label>
@ -455,6 +442,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addDownloadUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -469,56 +457,230 @@ 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 (another option is catbox.moe). Also, it's advisable
that you hash your package as well with your nostr public key.
updating it there (another option is{' '}
<a
href='https://catbox.moe/'
target='_blank'
rel='noopener noreferrer'
>
catbox.moe
</a>
). Also, it's advisable that you hash your package as well with your
nostr public key. Malware scan service suggestion:{' '}
<a
href='https://virustotal.com'
target='_blank'
rel='noopener noreferrer'
>
https://virustotal.com
</a>
</p>
{formState.downloadUrls.map((download, index) => (
<Fragment key={`download-${index}`}>
<DownloadUrlFields
index={index}
title={download.title}
url={download.url}
hash={download.hash}
signatureKey={download.signatureKey}
malwareScanLink={download.malwareScanLink}
modVersion={download.modVersion}
customNote={download.customNote}
mediaUrl={download.mediaUrl}
onUrlChange={handleDownloadUrlChange}
onRemove={removeDownloadUrl}
/>
{formErrors.downloadUrls && formErrors.downloadUrls[index] && (
<InputError message={formErrors.downloadUrls[index]} />
{formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
<InputError message={formErrors?.downloadUrls[index]} />
)}
</Fragment>
))}
{formState.downloadUrls.length === 0 &&
formErrors.downloadUrls &&
formErrors.downloadUrls[0] && (
<InputError message={formErrors.downloadUrls[0]} />
formErrors?.downloadUrls &&
formErrors?.downloadUrls[0] && (
<InputError message={formErrors?.downloadUrls[0]} />
)}
</div>
<div className='IBMSMSMBSSExtra'>
<button
className='btn btnMain IBMSMSMBSSExtraBtn'
type='button'
onClick={handleExtraBoxButtonClick}
>
Permissions &amp; Details
</button>
<div
className='IBMSMSMBSSExtraBox'
ref={extraBoxRef}
style={{
display: 'none'
}}
>
<p
className='labelDescriptionMain'
style={{ marginBottom: `10px`, textAlign: `center` }}
>
What permissions users have with your published mod/post
</p>
<div className='IBMSMSMBSSExtraBoxElementWrapper'>
{Object.keys(MODPERMISSIONS_CONF).map((k) => {
const permKey = k as keyof ModPermissions
const confKey = k as keyof typeof MODPERMISSIONS_CONF
const modPermission = MODPERMISSIONS_CONF[confKey]
const value = formState[permKey]
return (
<div className='IBMSMSMBSSExtraBoxElement' key={k}>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>{modPermission.header}</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<label
htmlFor={`${permKey}_true`}
className='IBMSMSMBSSExtraBoxElementColChoice'
>
<p>
{MODPERMISSIONS_DESC[`${permKey}_true`]}
<br />
</p>
<input
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
type='radio'
name={permKey}
id={`${permKey}_true`}
value={'true'}
checked={
typeof value !== 'undefined'
? value === true
: modPermission.default === true
}
onChange={(e) =>
handleRadioChange(
permKey,
e.currentTarget.value === 'true'
)
}
/>
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
</label>
<label
htmlFor={`${permKey}_false`}
className='IBMSMSMBSSExtraBoxElementColChoice'
>
<p>
{MODPERMISSIONS_DESC[`${permKey}_false`]}
<br />
</p>
<input
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
type='radio'
id={`${permKey}_false`}
value={'false'}
name={permKey}
checked={
typeof value !== 'undefined'
? value === false
: modPermission.default === false
}
onChange={(e) =>
handleRadioChange(
permKey,
e.currentTarget.value === 'true'
)
}
/>
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
</label>
</div>
</div>
)
})}
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Publisher Notes</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<textarea
className='inputMain'
value={formState.publisherNotes || ''}
onChange={(e) =>
handleInputChange('publisherNotes', e.currentTarget.value)
}
/>
</div>
</div>
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Extra Credits</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<textarea
className='inputMain'
value={formState.extraCredits || ''}
onChange={(e) =>
handleInputChange('extraCredits', e.currentTarget.value)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
type='button'
onClick={handlePublish}
disabled={isPublishing}
onClick={handleReset}
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
Publish
{isEditing ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
type='submit'
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
</button>
</div>
</>
{showTryAgainPopup && (
<AlertPopup
handleConfirm={handleTryAgainConfirm}
handleClose={() => setShowTryAgainPopup(false)}
header={'Publish'}
label={`Submission timed out. Do you want to try again?`}
/>
)}
{showConfirmPopup && (
<AlertPopup
handleConfirm={handleResetConfirm}
handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
isEditing
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
/>
)}
</form>
)
}
type DownloadUrlFieldsProps = {
index: number
url: string
title?: string
hash: string
signatureKey: string
malwareScanLink: string
modVersion: string
customNote: string
mediaUrl?: string
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
onRemove: (index: number) => void
}
@ -527,11 +689,13 @@ const DownloadUrlFields = React.memo(
({
index,
url,
title,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote,
mediaUrl,
onUrlChange,
onRemove
}: DownloadUrlFieldsProps) => {
@ -556,6 +720,7 @@ const DownloadUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -568,6 +733,28 @@ const DownloadUrlFields = React.memo(
</svg>
</button>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
name='title'
placeholder='Download Title'
value={title || ''}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
@ -678,6 +865,43 @@ const DownloadUrlFields = React.memo(
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
<ImageUpload
onChange={(values) => {
onUrlChange(index, 'mediaUrl', values[0])
}}
/>
<input
type='text'
className='inputMain'
placeholder='Media URL'
name='mediaUrl'
value={mediaUrl || ''}
onChange={handleChange}
/>
</div>
<div className='inputWrapperMainBox'></div>
</div>
</div>
)
}
@ -702,7 +926,7 @@ const ScreenshotUrlFields = React.memo(
type='text'
className='inputMain'
inputMode='url'
placeholder='We recommend to upload images to https://nostr.build/'
placeholder='Image URL'
value={url}
onChange={handleChange}
/>
@ -710,6 +934,7 @@ const ScreenshotUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -752,10 +977,13 @@ const GameDropdown = ({
}, [])
const filteredOptions = useMemo(() => {
if (debouncedSearchTerm === '') return []
const normalizedSearchTerm = normalizeSearchString(debouncedSearchTerm)
if (normalizedSearchTerm === '') return []
else {
return options.filter((option) =>
option.label.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
memoizedNormalizeSearchString(option.label).includes(
normalizedSearchTerm.toLowerCase()
)
)
}
}, [debouncedSearchTerm, options])
@ -790,6 +1018,7 @@ const GameDropdown = ({
className='btn btnMain btnMainInsideField btnMainRemove'
type='button'
onClick={() => onChange('game', '')}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -802,7 +1031,7 @@ const GameDropdown = ({
</svg>
</button>
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
<List
<FixedSizeList
height={500}
width={'100%'}
itemCount={filteredOptions.length}
@ -824,7 +1053,7 @@ const GameDropdown = ({
{filteredOptions[index].label}
</div>
)}
</List>
</FixedSizeList>
</div>
</div>
</div>

View File

@ -1,234 +0,0 @@
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'
>
{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
) {
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={`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>
)
}
)

View File

@ -0,0 +1,39 @@
import { AlertPopupProps } from 'types'
import { AlertPopup } from './AlertPopup'
import { useLocalStorage } from 'hooks'
type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'>
/**
* Triggers when the user wants to switch the filter to see any of the NSFW options
* (including preferences)
*
* Option will be remembered for the session only and will not show the popup again
*/
export const NsfwAlertPopup = ({
handleConfirm,
handleClose
}: NsfwAlertPopup) => {
const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>(
'confirm-nsfw',
false
)
return (
!confirmNsfw && (
<AlertPopup
header='Confirm'
label='Are you above 18 years of age?'
handleClose={() => {
handleConfirm(false)
handleClose()
}}
handleConfirm={(confirm: boolean) => {
setConfirmNsfw(confirm)
handleConfirm(confirm)
handleClose()
}}
/>
)
)
}

View File

@ -0,0 +1,48 @@
import { nip19 } from 'nostr-tools'
import { appRoutes, getProfilePageRoute } from 'routes'
import { npubToHex } from 'utils'
import { ProfileLink } from './ProfileLink'
interface OriginalAuthorProps {
value: string
fallback?: boolean
}
export const OriginalAuthor = ({
value,
fallback = false
}: OriginalAuthorProps) => {
let profilePubkey
let displayName = '[name not set up]'
// Try to decode/encode depending on what we send to link
let profileRoute = appRoutes.home
try {
if (value.startsWith('nprofile1')) {
const decoded = nip19.decode(value as `nprofile1${string}`)
profileRoute = getProfilePageRoute(value)
profilePubkey = decoded?.data.pubkey
} else if (value.startsWith('npub1')) {
profilePubkey = npubToHex(value)
const nprofile = profilePubkey
? nip19.nprofileEncode({
pubkey: profilePubkey
})
: undefined
if (nprofile) {
profileRoute = getProfilePageRoute(nprofile)
}
} else {
displayName = value
}
} catch (error) {
console.error('Failed to create profile link:', error)
displayName = value
}
if (profileRoute && profilePubkey)
return <ProfileLink pubkey={profilePubkey} profileRoute={profileRoute} />
return fallback ? displayName : null
}

View File

@ -0,0 +1,22 @@
interface PostWarningsProps {
type: 'user' | 'admin'
}
export const PostWarnings = ({ type }: PostWarningsProps) => (
<div className='IBMSMSMBSSWarning'>
<p>
{type === 'admin' ? (
<>
Warning: This post has been blocked/hidden by the site for one of the
following reasons:
<br />
Malware, Not a Mod, Illegal, Spam, Verified Report of Unauthorized
Repost.
<br />
</>
) : (
<>Notice: You have blocked this post</>
)}
</p>
</div>
)

View File

@ -0,0 +1,15 @@
import { useProfile } from 'hooks/useProfile'
import { Link } from 'react-router-dom'
interface ProfileLinkProps {
pubkey: string
profileRoute: string
}
export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => {
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
return <Link to={profileRoute}>{displayName}</Link>
}

View File

@ -26,7 +26,7 @@ import {
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 { NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
import { useProfile } from 'hooks/useProfile'
type Props = {
@ -93,7 +93,9 @@ type ProfileProps = {
}
export const Profile = ({ pubkey }: ProfileProps) => {
const profile = useProfile(pubkey)
const profile = useProfile(pubkey, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
@ -155,7 +157,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<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' && (
{typeof nip05 === 'string' && nip05 !== '' && (
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
)}
</div>
@ -191,7 +193,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
{typeof lud16 !== 'undefined' && (
{typeof lud16 !== 'undefined' && lud16 !== '' && (
<ZapButtonWithPopUp pubkey={pubkey} />
)}
</div>
@ -379,11 +381,25 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
}
})
// Hide follow if own profile
if (
userState.auth &&
userState.user?.pubkey &&
userState.user?.pubkey === pubkey
) {
return null
}
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
try {
return (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
return null
}
}
}

View File

@ -1,28 +1,28 @@
import { useFetcher } from 'react-router-dom'
import { CheckboxFieldUncontrolled } from 'components/Inputs'
import { useEffect } from 'react'
import { ReportReason } from 'types/report'
import { LoadingSpinner } from './LoadingSpinner'
import { PopupProps } from 'types'
type ReportPopupProps = {
handleClose: () => void
}
openedAt: number
reasons: ReportReason[]
} & PopupProps
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()
export const ReportPopup = ({
openedAt,
reasons,
handleClose
}: ReportPopupProps) => {
// Use openedAt to allow for multiple reports
// by default, fetcher will remember the data
const fetcher = useFetcher({ key: openedAt.toString() })
// Close automatically if action succeeds
useEffect(() => {
if (fetcher.data) {
const { isSent } = fetcher.data
console.log(fetcher.data)
if (isSent) {
handleClose()
}
@ -31,6 +31,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
return (
<>
{fetcher.state !== 'idle' && <LoadingSpinner desc={''} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
@ -65,7 +66,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
>
Why are you reporting this?
</label>
{BLOG_REPORT_REASONS.map((r) => (
{reasons.map((r) => (
<CheckboxFieldUncontrolled
key={r.key}
label={r.label}

View File

@ -3,10 +3,11 @@ import { forwardRef } from 'react'
interface SearchInputProps {
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
handleSearch: () => void
handleChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ handleKeyDown, handleSearch }, ref) => (
({ handleKeyDown, handleSearch, handleChange }, ref) => (
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
@ -15,6 +16,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
className='SMIWInput'
ref={ref}
onKeyDown={handleKeyDown}
onChange={handleChange}
placeholder='Enter search term'
/>
<button

View File

@ -2,6 +2,7 @@ import { getRelayListForUser } from '@nostr-dev-kit/ndk'
import { QRCodeSVG } from 'qrcode.react'
import React, {
Dispatch,
PropsWithChildren,
ReactNode,
SetStateAction,
useCallback,
@ -19,6 +20,9 @@ import {
formatNumber,
getTagValue,
getZapAmount,
log,
LogType,
timeout,
unformatNumber
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
@ -124,6 +128,7 @@ type ZapQRProps = {
handleQRExpiry: () => void
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
profileImage?: string
}
export const ZapQR = React.memo(
@ -132,8 +137,10 @@ export const ZapQR = React.memo(
handleClose,
handleQRExpiry,
setTotalZapAmount,
setHasZapped
}: ZapQRProps) => {
setHasZapped,
profileImage,
children
}: PropsWithChildren<ZapQRProps>) => {
const { ndk } = useNDKContext()
useDidMount(() => {
@ -173,7 +180,10 @@ export const ZapQR = React.memo(
}
return (
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
<div
className='inputLabelWrapperMain inputLabelWrapperMainQR'
style={{ alignItems: 'center' }}
>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
@ -181,6 +191,21 @@ export const ZapQR = React.memo(
height={235}
width={235}
/>
{profileImage && (
<div style={{ marginTop: '-20px' }}>
<img
src={profileImage}
alt='Profile Avatar'
style={{
width: '100%',
maxWidth: '50px',
borderRadius: '8px',
border: 'solid 2px #494949',
boxShadow: '0 0 4px 0 rgb(0, 0, 0, 0.1)'
}}
/>
</div>
)}
<label
className='popUpMainCardBottomLnurl'
onClick={() => {
@ -192,6 +217,7 @@ export const ZapQR = React.memo(
{paymentRequest.pr}
</label>
<Timer onTimerExpired={handleQRExpiry} />
{children}
</div>
)
}
@ -258,7 +284,7 @@ export const ZapPopUp = ({
const [amount, setAmount] = useState<number>(0)
const [message, setMessage] = useState('')
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const [receiverMetadata, setRecieverMetadata] = useState<UserProfile>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -268,7 +294,7 @@ export const ZapPopUp = ({
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
let userHexKey: string | undefined
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -276,7 +302,11 @@ export const ZapPopUp = ({
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -285,7 +315,7 @@ export const ZapPopUp = ({
return null
}
setLoadingSpinnerDesc('finding receiver metadata')
setLoadingSpinnerDesc('Finding receiver metadata')
const receiverMetadata = await findMetadata(receiver)
@ -297,12 +327,17 @@ export const ZapPopUp = ({
if (!receiverMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in receiver metadata!')
toast.error('Pubkey is missing in receiver metadata!')
return null
}
setRecieverMetadata(receiverMetadata)
// Find the receiver's read relays.
const receiverRelays = await getRelayListForUser(receiver, ndk)
const receiverRelays = await Promise.race([
getRelayListForUser(receiver, ndk),
timeout(2000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
@ -468,6 +503,7 @@ export const ZapPopUp = ({
handleQRExpiry={handleQRExpiry}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
profileImage={receiverMetadata?.image}
/>
)}
{lastNode}
@ -548,7 +584,7 @@ export const ZapSplit = ({
const generatePaymentInvoices = async () => {
if (!amount) return null
let userHexKey: string
let userHexKey: string | undefined
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -556,7 +592,11 @@ export const ZapSplit = ({
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -614,7 +654,11 @@ export const ZapSplit = ({
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
// Find the receiver's read relays.
const adminRelays = await getRelayListForUser(admin.pubkey as string, ndk)
// TODO: NDK should have native timeout in a future release
const adminRelays = await Promise.race([
getRelayListForUser(admin.pubkey as string, ndk),
timeout(2000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
@ -715,6 +759,8 @@ export const ZapSplit = ({
toast.warn('Webln is not present. Use QR code to send zap.')
setInvoices(paymentInvoices)
}
setIsLoading(false)
}
const removeInvoice = (key: string) => {
@ -729,6 +775,56 @@ export const ZapSplit = ({
if (!invoices) return null
const authorInvoice = invoices.get('author')
const feedback = (isFirst: boolean) => (
<div
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
flexWrap: 'wrap',
gridGap: '10px'
}}
>
<div
className='btn btnMain'
style={{
flexGrow: 1,
cursor: 'default',
background: isFirst ? undefined : 'unset'
}}
>
<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>
1st Invoice
</div>
<div
className='btn btnMain'
style={{
flexGrow: 1,
cursor: 'default',
background: isFirst ? 'unset' : undefined
}}
>
<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>
2nd Invoice
</div>
</div>
)
if (authorInvoice) {
return (
<ZapQR
@ -738,7 +834,10 @@ export const ZapSplit = ({
handleQRExpiry={() => removeInvoice('author')}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
/>
profileImage={author?.image}
>
{feedback(true)}
</ZapQR>
)
}
@ -753,7 +852,10 @@ export const ZapSplit = ({
handleClose()
}}
handleQRExpiry={() => removeInvoice('admin')}
/>
profileImage={admin?.image}
>
{feedback(false)}
</ZapQR>
)
}

View File

@ -0,0 +1,157 @@
import { NDKKind } from '@nostr-dev-kit/ndk'
import { formatDate } from 'date-fns'
import { useDidMount, useNDKContext } from 'hooks'
import { useState } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom'
import { getModPageRoute, getBlogPageRoute, getProfilePageRoute } from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { nip19 } from 'nostr-tools'
import { CommentContent } from './CommentContent'
interface CommentProps {
comment: CommentEvent
}
export const Comment = ({ comment }: CommentProps) => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: undefined
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
comment.event.author.fetchProfile().then((res) => setProfile(res))
ndk
.fetchEvents({
kinds: [NDKKind.Text, NDKKind.GenericReply],
'#e': [comment.event.id]
})
.then((ndkEventsSet) => {
setCommentEvents(
Array.from(ndkEventsSet).map((ndkEvent) => ({
event: ndkEvent
}))
)
})
})
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: comment.event.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(comment.event.pubkey)}
</Link>
</div>
{comment.event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(comment.event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(comment.event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
{comment.status && (
<p className='IBMSMSMBSSCL_CBTextStatus'>
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
{comment.status}
</p>
)}
<CommentContent content={comment.event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...comment.event.rawEvent()} />
{/* <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> */}
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
<Zap {...comment.event.rawEvent()} />
)}
{comment.event.kind === NDKKind.GenericReply && (
<>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
to={baseUrl + comment.event.encode()}
>
<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'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</Link>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
to={baseUrl + comment.event.encode()}
>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</Link>
</>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { useTextLimit } from 'hooks/useTextLimit'
interface CommentContentProps {
content: string
}
export const CommentContent = ({ content }: CommentContentProps) => {
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
return (
<>
<p className='IBMSMSMBSSCL_CBText'>{text}</p>
{isTextOverflowing && (
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
<p>{isExpanded ? 'Hide' : 'View'} full post</p>
</div>
)}
</>
)
}

View File

@ -0,0 +1,41 @@
import { useState } from 'react'
type CommentFormProps = {
handleSubmit: (content: string) => Promise<boolean>
}
export 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>
)
}

View File

@ -0,0 +1,335 @@
import { formatDate } from 'date-fns'
import { useBodyScrollDisable, useNDKContext, useReplies } from 'hooks'
import { nip19 } from 'nostr-tools'
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'
import {
Link,
useLoaderData,
useLocation,
useNavigate,
useParams
} from 'react-router-dom'
import { getBlogPageRoute, getModPageRoute, getProfilePageRoute } from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { CommentsLoaderResult } from 'types/comments'
import { adjustTextareaHeight, handleCommentSubmit, hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { NDKKind } from '@nostr-dev-kit/ndk'
import { Comment } from './Comment'
import { useComments } from 'hooks/useComments'
import { CommentContent } from './CommentContent'
import { Dots } from 'components/Spinner'
export const CommentsPopup = () => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
useBodyScrollDisable(true)
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: undefined
const { event } = useLoaderData() as CommentsLoaderResult
const {
size,
parent: replyEvent,
isComplete,
root: rootEvent
} = useReplies(event.tagValue('e'))
const isRoot = event.tagValue('a') === event.tagValue('A')
const [profile, setProfile] = useState<UserProfile>()
const { commentEvents, setCommentEvents } = useComments(
event.author.pubkey,
undefined,
event.id
)
useEffect(() => {
event.author.fetchProfile().then((res) => setProfile(res))
}, [event.author])
const profileRoute = useMemo(
() =>
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: event.pubkey
})
),
[event.pubkey]
)
const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [replyText, setReplyText] = useState('')
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.currentTarget.value
setReplyText(value)
adjustTextareaHeight(e.currentTarget)
}, [])
const [visible, setVisible] = useState<CommentEvent[]>([])
const discoveredCount = commentEvents.length - visible.length
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(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(replyText)
if (submitted) setReplyText('')
setIsSubmitting(false)
}
return (
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Comment replies</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => navigate('..')}
>
<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'>
<div className='pUMCB_PrimeComment'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTopOther'>
<div className='IBMSMSMBSSCL_CTO'>
{replyEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='IBMSMSMBSSCL_CTOLink'
to={baseUrl + replyEvent.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CTOLinkIcon'
>
<path d='M447.1 256C447.1 273.7 433.7 288 416 288H109.3l105.4 105.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448s-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.25L109.3 224H416C433.7 224 447.1 238.3 447.1 256z'></path>
</svg>
</Link>
)}
<p className='IBMSMSMBSSCL_CTOText'>
Reply Depth:&nbsp;<span>{size}</span>
{!isComplete && <Dots />}
</p>
</div>
{!isRoot && rootEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='btn btnMain IBMSMSMBSSCL_CTOBtn'
type='button'
to={baseUrl + rootEvent.encode()}
>
Main Post {!isComplete && <Dots />}
</Link>
)}
</div>
<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(event.pubkey)}
</Link>
</div>
{event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...event.rawEvent()} />
{/* <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> */}
{typeof profile?.lud16 !== 'undefined' &&
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
{event.kind === NDKKind.GenericReply && (
<>
<span className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'>
<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'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>
Replies
</p>
</span>
</>
)}
</div>
</div>
</div>
</div>
<div className='pUMCB_CommentToPrime'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
className='IBMSMSMBSSCC_Top_Box postSocialTextarea'
placeholder='Got something to say?'
value={replyText}
onChange={handleChange}
style={{ height: '0px' }}
></textarea>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
{/* <a className='IBMSMSMBSSCC_BottomButton'>Quote-Repost</a> */}
<button
onClick={handleComment}
disabled={isSubmitting}
className='IBMSMSMBSSCC_BottomButton'
>
{isSubmitting ? 'Replying...' : 'Reply'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
{commentEvents.length > 0 && (
<>
<h3 className='IBMSMSMBSSCL_CommentNoteRepliesTitle'>
Replies
<button
type='button'
className='btnMain IBMSMSMBSSCL_CommentNoteRepliesTitleBtn'
onClick={
discoveredCount ? handleDiscoveredClick : undefined
}
>
<span>
{isLoading ? (
<>
Discovering replies
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered replies</>
) : (
<>No new replies</>
)}
</span>
</button>
</h3>
<div className='pUMCB_RepliesToPrime'>
{commentEvents.map((reply) => (
<Comment key={reply.event.id} comment={reply} />
))}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import React, { Dispatch, SetStateAction } from 'react'
import { AuthorFilterEnum, SortByEnum } from 'types'
export type FilterOptions = {
sort: SortByEnum
author: AuthorFilterEnum
}
type FilterProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
export 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>
)
}
)

View File

@ -0,0 +1,68 @@
import { NostrEvent } from '@nostr-dev-kit/ndk'
import { Dots } from 'components/Spinner'
import { useReactions } from 'hooks'
export const Reactions = (props: NostrEvent) => {
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>
</>
)
}

View File

@ -0,0 +1,76 @@
import { NostrEvent } from '@nostr-dev-kit/ndk'
import { ZapPopUp } from 'components/Zap'
import {
useAppSelector,
useNDKContext,
useBodyScrollDisable,
useDidMount
} from 'hooks'
import { useState } from 'react'
import { toast } from 'react-toastify'
import { abbreviateNumber } from 'utils'
export const Zap = (props: NostrEvent) => {
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}
/>
)}
</>
)
}

View File

@ -1,48 +1,20 @@
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 { Dots } from 'components/Spinner'
import { useNDKContext } 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 { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
import { useLoaderData } from 'react-router-dom'
import {
Addressable,
AuthorFilterEnum,
BlogPageLoaderResult,
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
}
ModPageLoaderResult,
SortByEnum
} from 'types'
import { handleCommentSubmit } from 'utils'
import { Filter, FilterOptions } from './Filter'
import { CommentForm } from './CommentForm'
import { Comment } from './Comment'
type Props = {
addressable: Addressable
@ -50,11 +22,14 @@ type Props = {
}
export const Comments = ({ addressable, setCommentCount }: Props) => {
const { ndk, publish } = useNDKContext()
const { ndk } = useNDKContext()
const { commentEvents, setCommentEvents } = useComments(
addressable.author,
addressable.aTag
)
const { event } = useLoaderData() as
| ModPageLoaderResult
| BlogPageLoaderResult
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
@ -73,122 +48,16 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
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[]>([])
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
@ -199,14 +68,22 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.pubkey === addressable.author
(comment) => comment.event.pubkey === addressable.author
)
}
if (filterOptions.sort === SortByEnum.Latest) {
filteredComments.sort((a, b) => b.created_at - a.created_at)
filteredComments.sort((a, b) =>
a.event.created_at && b.event.created_at
? b.event.created_at - a.event.created_at
: 0
)
} else if (filterOptions.sort === SortByEnum.Oldest) {
filteredComments.sort((a, b) => a.created_at - b.created_at)
filteredComments.sort((a, b) =>
a.event.created_at && b.event.created_at
? a.event.created_at - b.event.created_at
: 0
)
}
return filteredComments
@ -220,380 +97,35 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
{/* 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>
)}
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>
{isLoading ? (
<>
Discovering comments
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered comments</>
) : (
<>No new comments</>
)}
</span>
</button>
</div>
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSMSMBSSCommentsList'>
{comments.map((event) => (
<Comment key={event.id} {...event} />
{comments.map((comment) => (
<Comment key={comment.event.id} comment={comment} />
))}
</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}
/>
)}
</>
)
}

View File

@ -24,7 +24,7 @@ export const LANDING_PAGE_DATA = {
featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qyv8wumn8ghj7un9d3shjtnyv4nk6mmywvhxxmmd9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3qamnwvaz7tmwdaehgu3wd4hk6tcppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpz4mhxue69uhkummnw3ezummcw3ezuer9wchsqfrxv33rvvfjxucz6d33vgcz6dp48qej6wryv9jz6errv33nqef3xy6kxvmrtmq496',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
]
}
@ -123,3 +123,4 @@ export const FALLBACK_PROFILE_IMAGE =
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
export const PROFILE_BLOG_FILTER_LIMIT = 20
export const MAX_VISIBLE_TEXT_PER_COMMENT = 500

View File

@ -6,6 +6,7 @@ import NDK, {
NDKList,
NDKRelaySet,
NDKSubscriptionCacheUsage,
NDKSubscriptionOptions,
NDKUser,
zapInvoiceFromEvent
} from '@nostr-dev-kit/ndk'
@ -48,7 +49,10 @@ export interface NDKContextType {
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent | null>
findMetadata: (pubkey: string) => Promise<UserProfile>
findMetadata: (
pubkey: string,
opts?: NDKSubscriptionOptions
) => Promise<UserProfile>
getTotalZapAmount: (
user: string,
eTag: string,
@ -74,7 +78,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
window.onunhandledrejection = async (event: PromiseRejectionEvent) => {
event.preventDefault()
console.log(event.reason)
if (event.reason?.name === Dexie.errnames.DatabaseClosed) {
console.log(
'Could not open Dexie DB, probably version change. Deleting old DB and reloading...'
@ -112,7 +115,12 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
const ndk = useMemo(() => {
localStorage.setItem('debug', '*')
if (import.meta.env.MODE === 'development') {
localStorage.setItem('debug', '*')
} else {
localStorage.removeItem('debug')
}
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
dexieAdapter.locking = true
const ndk = new NDK({
@ -245,7 +253,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
// Find the user's relays (10s timeout).
const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
timeout(3000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
@ -253,7 +261,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
})
.catch((err) => {
log(
true,
false, // Too many failed requests, turned off for clarity
LogType.Error,
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
@ -265,7 +273,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
.fetchEvents(
filter,
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
relayUrls.length
? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
: undefined
)
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
@ -308,13 +318,16 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
* @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 findMetadata = async (
pubkey: string,
opts?: NDKSubscriptionOptions
): Promise<UserProfile> => {
const npub = hexToNpub(pubkey)
const user = new NDKUser({ npub })
user.ndk = ndk
const userProfile = await user.fetchProfile()
const userProfile = await user.fetchProfile(opts)
return userProfile
}
@ -368,16 +381,14 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
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 []
})
try {
const res = await event.publish(undefined, 10000)
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
} catch (err) {
console.error(`An error occurred in publishing event`, err)
return []
}
}
/**

View File

@ -0,0 +1,74 @@
import { DropzoneOptions } from 'react-dropzone'
import { NostrCheckServer } from './nostrcheck-server'
import { BaseError } from 'types'
export interface MediaOperations {
post: (file: File) => Promise<string>
}
export type MediaStrategy = Omit<MediaOperations, 'auth'>
export interface MediaOption {
name: string
host: string
type: 'nostrcheck-server' | 'route96'
}
// nostr.build based dropzone options
export const MEDIA_DROPZONE_OPTIONS: DropzoneOptions = {
maxSize: 7000000,
accept: {
'image/*': ['.jpeg', '.png', '.jpg', '.gif', '.webp']
}
}
export const MEDIA_OPTIONS: MediaOption[] = [
// {
// name: 'nostr.build',
// host: 'https://nostr.build/',
// type: 'nostrcheck-server'
// },
{
name: 'nostrcheck.me',
host: 'https://nostrcheck.me/',
type: 'nostrcheck-server'
},
{
name: 'nostpic.com',
host: 'https://nostpic.com/',
type: 'nostrcheck-server'
},
{
name: 'files.sovbit.host',
host: 'https://files.sovbit.host/',
type: 'nostrcheck-server'
}
// {
// name: 'void.cat',
// host: 'https://void.cat/',
// type: 'route96'
// }
]
enum ImageErrorType {
'TYPE_MISSING' = 'Media Option must include a type.'
}
export class ImageController implements MediaStrategy {
post: (file: File) => Promise<string>
constructor(mediaOption: MediaOption) {
let strategy: MediaStrategy
switch (mediaOption.type) {
case 'nostrcheck-server':
strategy = new NostrCheckServer(mediaOption.host)
this.post = strategy.post
break
case 'route96':
throw new Error('Not implemented.')
default:
throw new BaseError(ImageErrorType.TYPE_MISSING)
}
}
}

View File

@ -0,0 +1,166 @@
import axios, { isAxiosError } from 'axios'
import { NostrEvent, NDKKind } from '@nostr-dev-kit/ndk'
import { type MediaOperations } from '.'
import { store } from 'store'
import { log, LogType, now } from 'utils'
import { BaseError, handleError } from 'types'
// https://github.com/quentintaranpino/nostrcheck-server/blob/main/DOCS.md#media-post
// Response object (other fields omitted for brevity)
// {
// "status": "success",
// "nip94_event": {
// "tags": [
// [
// "url",
// "https://nostrcheck.me/media/62c76eb094369d938f5895442eef7f53ebbf019f69707d64e77d4d182b609309/c35277dbcedebb0e3b80361762c8baadb66dcdfb6396949e50630159a472c3b2.webp"
// ],
// ],
// }
// }
interface Response {
status: 'success' | string
nip94_event?: {
tags?: string[][]
}
}
enum HandledErrorType {
'PUBKEY' = 'Failed to get public key.',
'SIGN' = 'Failed to sign the event.',
'AXIOS_REQ' = 'Image upload failed. Try another host from the dropdown.',
'AXIOS_RES' = 'Image upload failed. Reason: ',
'AXIOS_ERR' = 'Image upload failed.',
'NOSTR_CHECK_NO_SUCCESS' = 'Image upload was unsuccesfull.',
'NOSTR_CHECK_BAD_EVENT' = 'Image upload failed. Please try again.'
}
export class NostrCheckServer implements MediaOperations {
#media = 'api/v2/media'
#url: string
constructor(url: string) {
this.#url = url[url.length - 1] === '/' ? url : `${url}/`
}
post = async (file: File) => {
const url = `${this.#url}${this.#media}`
const auth = await this.auth()
try {
const response = await axios.postForm<Response>(
url,
{
uploadType: 'media',
file: file
},
{
headers: {
Authorization: 'Nostr ' + auth,
'Content-Type': 'multipart/form-data'
},
responseType: 'json'
}
)
if (response.data.status !== 'success') {
throw new BaseError(HandledErrorType.NOSTR_CHECK_NO_SUCCESS, {
context: { ...response.data }
})
}
if (
response.data &&
response.data.nip94_event &&
response.data.nip94_event.tags &&
response.data.nip94_event.tags.length
) {
// Return first 'url' tag we find on the returned nip94 event
const imageUrl = response.data.nip94_event.tags.find(
(item) => item[0] === 'url'
)
if (imageUrl) return imageUrl[1]
}
throw new BaseError(HandledErrorType.NOSTR_CHECK_BAD_EVENT, {
context: { ...response.data }
})
} catch (error) {
// Handle axios errors
if (isAxiosError(error)) {
if (error.request) {
// The request was made but no response was received
throw new BaseError(HandledErrorType.AXIOS_REQ, {
cause: error
})
} else if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// nostrcheck-server can return different results, including message or description
const data = error.response.data
let message = error.message
if (data) {
message = data?.message || data?.description || error.message
}
throw new BaseError(HandledErrorType.AXIOS_RES + message, {
cause: error
})
} else {
// Something happened in setting up the request that triggered an Error
throw new BaseError(HandledErrorType.AXIOS_ERR, {
cause: error
})
}
} else if (error instanceof BaseError) {
throw error
} else {
throw handleError(error)
}
}
}
auth = async () => {
try {
const url = `${this.#url}${this.#media}`
let hexPubkey: string | undefined
const userState = store.getState().user
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {
throw new BaseError(HandledErrorType.PUBKEY)
}
const unsignedEvent: NostrEvent = {
content: '',
created_at: now(),
kind: NDKKind.HttpAuth,
pubkey: hexPubkey,
tags: [
['u', url],
['method', 'POST']
]
}
const signedEvent = await window.nostr?.signEvent(unsignedEvent)
return btoa(JSON.stringify(signedEvent))
} catch (error) {
if (error instanceof BaseError) {
throw error
}
throw new BaseError(HandledErrorType.SIGN, {
cause: handleError(error)
})
}
}
}

View File

@ -0,0 +1,7 @@
import { MediaOperations } from '.'
export class route96 implements MediaOperations {
post = () => {
throw new Error('route96 post Not implemented.')
}
}

View File

@ -1 +1,2 @@
export * from './zap'
export * from './image'

View File

@ -4,7 +4,11 @@ export * from './useFilteredMods'
export * from './useGames'
export * from './useMuteLists'
export * from './useNSFWList'
export * from './useRepostList'
export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'
export * from './useSessionStorage'
export * from './useLocalCache'
export * from './useReplies'

View File

@ -13,14 +13,15 @@ import { useNDKContext } from './useNDKContext'
export const useComments = (
author: string | undefined,
aTag: string | undefined
aTag: string | undefined,
eTag?: string | undefined
) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => {
if (!(author && aTag)) {
// Author and aTag are required
if (!(author && (aTag || eTag))) {
// Author and aTag/eTag are required
return
}
@ -39,7 +40,7 @@ export const useComments = (
})
.catch((err) => {
log(
true,
false, // Too many failed requests, turned off for clarity
LogType.Error,
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
err
@ -48,8 +49,17 @@ export const useComments = (
})
const filter: NDKFilter = {
kinds: [NDKKind.Text],
'#a': [aTag]
kinds: [NDKKind.Text, NDKKind.GenericReply],
...(aTag
? {
'#a': [aTag]
}
: {}),
...(eTag
? {
'#e': [eTag]
}
: {})
}
const relayUrls = new Set<string>()
@ -66,26 +76,18 @@ export const useComments = (
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
},
NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
relayUrls.size
? NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
: undefined
)
subscription.on('event', (ndkEvent) => {
setCommentEvents((prev) => {
if (prev.find((e) => e.id === ndkEvent.id)) {
if (prev.find((e) => e.event.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]
return [{ event: ndkEvent }, ...prev]
})
})
@ -100,7 +102,7 @@ export const useComments = (
subscription.stop()
}
}
}, [aTag, author, ndk])
}, [aTag, author, eTag, ndk])
return {
commentEvents,

View File

@ -0,0 +1,15 @@
import { useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
import { CurationSetIdentifiers, getReportingSet } from 'utils'
export const useCuratedSet = (type: CurationSetIdentifiers) => {
const ndkContext = useNDKContext()
const [curatedSet, setCuratedSet] = useState<string[]>([])
useDidMount(async () => {
setCuratedSet(await getReportingSet(type, ndkContext))
})
return curatedSet
}

View File

@ -6,6 +6,7 @@ import {
ModeratedFilter,
MuteLists,
NSFWFilter,
RepostFilter,
SortBy,
WOTFilterOptions
} from 'types'
@ -22,6 +23,7 @@ export const useFilteredMods = (
admin: MuteLists
user: MuteLists
},
repostList: string[],
author?: string | undefined
) => {
const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector(
@ -53,30 +55,71 @@ export const useFilteredMods = (
}
}
const repostFilter = (mods: ModDetails[]) => {
if (filterOptions.repost !== RepostFilter.Hide_Repost) {
// Add repost tag to mods included in repostList
mods = mods.map((mod) => {
return !mod.repost && repostList.includes(mod.aTag)
? { ...mod, repost: true }
: mod
})
}
// Determine the filtering logic based on the Repost filter option
switch (filterOptions.repost) {
case RepostFilter.Hide_Repost:
return mods.filter(
(mod) => !mod.repost && !repostList.includes(mod.aTag)
)
case RepostFilter.Show_Repost:
return mods
case RepostFilter.Only_Repost:
return mods.filter(
(mod) => mod.repost || repostList.includes(mod.aTag)
)
}
}
const wotFilter = (mods: ModDetails[]) => {
// Determine the filtering logic based on the WOT filter option
// 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:
return mods
// 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:
return mods.filter((mod) =>
isInWoT(userWot, userWotLevel, mod.author)
)
// 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(siteWot, siteWotLevel, mod.author) &&
isInWoT(userWot, userWotLevel, mod.author)
)
}
}
let filtered = nsfwFilter(mods)
filtered = repostFilter(filtered)
filtered = wotFilter(filtered)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
@ -85,10 +128,19 @@ export const useFilteredMods = (
npubToHex(userState.user.npub as string) === author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
const isOnlyBlocked =
filterOptions.moderated === ModeratedFilter.Only_Blocked
// 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)) {
if (isOnlyBlocked && isAdmin) {
filtered = filtered.filter(
(mod) =>
muteLists.admin.authors.includes(mod.author) ||
muteLists.admin.replaceableEvents.includes(mod.aTag)
)
} else if (isUnmoderatedFully && (isAdmin || isOwner)) {
// 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
} else {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
@ -112,15 +164,18 @@ export const useFilteredMods = (
return filtered
}, [
userState.auth,
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
filterOptions.wot,
filterOptions.nsfw,
filterOptions.repost,
author,
mods,
muteLists,
nsfwList,
repostList,
siteWot,
siteWotLevel,
userWot,

View File

@ -5,11 +5,14 @@ import { Game } from 'types'
import { log, LogType } from 'utils'
import gameFiles from '../utils/games'
let cachedGamesData: Game[] | null = null
export const useGames = () => {
const hasProcessedFiles = useRef(false)
const [games, setGames] = useState<Game[]>([])
const [games, setGames] = useState<Game[]>(cachedGamesData || [])
useEffect(() => {
if (cachedGamesData) return
if (hasProcessedFiles.current) return
hasProcessedFiles.current = true
@ -52,6 +55,7 @@ export const useGames = () => {
})
await Promise.all(promises)
cachedGamesData = uniqueGames
setGames(uniqueGames)
} catch (err) {
log(

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react'
import { setLocalStorageItem, removeLocalStorageItem } from 'utils'
export function useLocalCache<T>(
key: string
): [
T | undefined,
React.Dispatch<React.SetStateAction<T | undefined>>,
() => void
] {
const [cache, setCache] = useState<T | undefined>(() => {
const storedValue = window.localStorage.getItem(key)
if (storedValue === null) return undefined
// Parse the value
const parsedStoredValue = JSON.parse(storedValue)
return parsedStoredValue
})
useEffect(() => {
try {
if (cache) {
setLocalStorageItem(key, JSON.stringify(cache))
} else {
removeLocalStorageItem(key)
}
} catch (e) {
console.warn(e)
}
}, [cache, key])
const clearCache = useCallback(() => {
setCache(undefined)
}, [])
return [cache, setCache, clearCache]
}

View File

@ -1,6 +1,7 @@
import React from 'react'
import React, { useMemo } from 'react'
import {
getLocalStorageItem,
mergeWithInitialValue,
removeLocalStorageItem,
setLocalStorageItem
} from 'utils'
@ -10,13 +11,6 @@ const useLocalStorageSubscribe = (callback: () => void) => {
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
@ -64,5 +58,7 @@ export function useLocalStorage<T>(
}
}, [key, initialValue])
return [JSON.parse(data) as T, setState]
const memoized = useMemo(() => JSON.parse(data) as T, [data])
return [memoized, setState]
}

View File

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

View File

@ -70,12 +70,16 @@ export const useReactions = (params: UseReactionsParams) => {
}, [reactionEvents, userState])
const getPubkey = async () => {
let hexPubkey: string
let hexPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {

53
src/hooks/useReplies.tsx Normal file
View File

@ -0,0 +1,53 @@
import {
NDKEvent,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
export const useReplies = (eTag: string | undefined) => {
const { ndk } = useNDKContext()
const [replies, setReplies] = useState<NDKEvent[]>([])
const [isComplete, setIsComplete] = useState(false)
useDidMount(async () => {
if (!eTag) {
setIsComplete(true)
return
}
let eDepth: string | undefined = eTag
while (eDepth) {
const previousReply = await ndk.fetchEvent(
{
kinds: [NDKKind.Text, NDKKind.GenericReply],
ids: [eDepth]
},
{
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
}
)
if (previousReply) {
setReplies((p) => {
if (p.findIndex((p) => p.id === previousReply.id) === -1) {
p.push(previousReply)
}
return p
})
eDepth = previousReply.tagValue('e')
} else {
eDepth = undefined
}
}
setIsComplete(true)
})
return {
size: replies.length,
isComplete,
parent: replies.length > 0 ? replies[0] : undefined,
root: isComplete ? replies[replies.length - 1] : undefined
}
}

View File

@ -0,0 +1,19 @@
import { useState } from 'react'
import { useDidMount } from './useDidMount'
import { useNDKContext } from './useNDKContext'
import { CurationSetIdentifiers, getReportingSet } from 'utils'
export const useRepostList = () => {
const ndkContext = useNDKContext()
const [repostList, setRepostList] = useState<string[]>([])
useDidMount(async () => {
const list = await getReportingSet(
CurationSetIdentifiers.Repost,
ndkContext
)
setRepostList(list)
})
return repostList
}

View File

@ -0,0 +1,67 @@
import React, { useMemo } from 'react'
import {
getSessionStorageItem,
mergeWithInitialValue,
removeSessionStorageItem,
setSessionStorageItem
} from 'utils'
const useSessionStorageSubscribe = (callback: () => void) => {
window.addEventListener('sessionStorage', callback)
return () => window.removeEventListener('sessionStorage', callback)
}
export function useSessionStorage<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const getSnapshot = () => {
// Get the stored value
const storedValue = getSessionStorageItem(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(
useSessionStorageSubscribe,
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) {
removeSessionStorageItem(key)
} else {
setSessionStorageItem(key, JSON.stringify(nextState))
}
} catch (e) {
console.warn(e)
}
},
[data, key]
)
React.useEffect(() => {
// Set session storage only when it's empty
const data = window.sessionStorage.getItem(key)
if (data === null) {
setSessionStorageItem(key, JSON.stringify(initialValue))
}
}, [key, initialValue])
const memoized = useMemo(() => JSON.parse(data) as T, [data])
return [memoized, setState]
}

View File

@ -0,0 +1,19 @@
import { MAX_VISIBLE_TEXT_PER_COMMENT } from '../constants'
import { useState } from 'react'
export const useTextLimit = (
text: string,
limit: number = MAX_VISIBLE_TEXT_PER_COMMENT
) => {
const [isExpanded, setIsExpanded] = useState(false)
const isTextOverflowing = text.length > limit
const updated =
isExpanded || !isTextOverflowing ? text : text.slice(0, limit) + '…'
return {
text: updated,
isTextOverflowing,
isExpanded,
toggle: () => setIsExpanded((prev) => !prev)
}
}

View File

@ -1,10 +1,39 @@
import { Outlet } from 'react-router-dom'
import { Profile } from 'components/ProfileSection'
import { useAppSelector } from 'hooks'
import { Navigate, Outlet } from 'react-router-dom'
import { appRoutes } from 'routes'
export const FeedLayout = () => {
const userState = useAppSelector((state) => state.user)
if (!userState.user?.pubkey) return <Navigate to={appRoutes.home} />
return (
<div className='InnerBodyMain'>
<h1>WIP</h1>
<Outlet />
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainFullSide'>
<div className='IBMSMSplitMainFullSideFeedWrapper'>
<div className='IBMSMSplitMainFullSideFWSide'>
{userState.auth && userState.user?.pubkey && (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile pubkey={userState.user.pubkey as string} />
</div>
</div>
</div>
)}
</div>
<div className='IBMSMSplitMainFullSideFWMid'>
<Outlet />
</div>
<div className='IBMSMSplitMainFullSideFWSide'></div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,4 +1,6 @@
import { Link } from 'react-router-dom'
import styles from '../styles/footer.module.scss'
import { appRoutes, getProfilePageRoute } from 'routes'
export const Footer = () => {
return (
@ -7,6 +9,7 @@ export const Footer = () => {
<p className={styles.secMainFooterPara}>
Built with&nbsp;
<a
rel='noopener'
className={styles.secMainFooterParaLink}
href='https://github.com/nostr-protocol/nostr'
target='_blank'
@ -14,21 +17,26 @@ export const Footer = () => {
Nostr
</a>{' '}
by&nbsp;
<a
<Link
className={styles.secMainFooterParaLink}
href='https://degmods.com/profile/nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
to={getProfilePageRoute(
'nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
)}
target='_blank'
>
Freakoverse
</a>
</Link>
, with the support of{' '}
<a className={styles.secMainFooterParaLink} href='backers.html'>
<Link
className={styles.secMainFooterParaLink}
to={appRoutes.supporters}
>
Supporters
</a>
</Link>
. Check our&nbsp;
<a className={styles.secMainFooterParaLink} href='backup.html'>
<Link className={styles.secMainFooterParaLink} to={appRoutes.backup}>
Backup Plan
</a>
</Link>
.
</p>
</div>

View File

@ -3,7 +3,7 @@ import {
launch as launchNostrLoginDialog
} from 'nostr-login'
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useRevalidator } from 'react-router-dom'
import { Banner } from '../components/Banner'
import { ZapPopUp } from '../components/Zap'
import {
@ -22,12 +22,13 @@ 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'
import { NDKNip07Signer, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
export const Header = () => {
const dispatch = useAppDispatch()
const { findMetadata } = useNDKContext()
const { findMetadata, ndk } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const revalidator = useRevalidator()
// Track nostr-login extension modal open state
const [isOpen, setIsOpen] = useState(false)
const handleOpen = () => setIsOpen(true)
@ -50,6 +51,7 @@ export const Header = () => {
dispatch(setAuth(null))
dispatch(setUser(null))
dispatch(resetUserWot())
ndk.signer = undefined
} else {
dispatch(
setAuth({
@ -63,7 +65,10 @@ export const Header = () => {
pubkey: npubToHex(npub)!
})
)
findMetadata(npub).then((userProfile) => {
ndk.signer = new NDKNip07Signer()
findMetadata(npub, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
}).then((userProfile) => {
if (userProfile) {
dispatch(
setUser({
@ -75,8 +80,12 @@ export const Header = () => {
}
})
}
// React router - revalidate loader states on auth changes
revalidator.revalidate()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, findMetadata])
const handleLogin = () => {
@ -91,10 +100,7 @@ export const Header = () => {
<div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainTopInside}>
<div className={navStyles.NMTI_Sec}>
<Link
to={appRoutes.index}
className={navStyles.NMTI_Sec_HomeLink}
>
<Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
<img
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
@ -439,6 +445,21 @@ const RegisterButtonWithDialog = () => {
nos2x
</a>
</div>
<p
className='labelDescriptionMain'
style={{
padding: '10px',
borderRadius: '10px',
background: 'rgba(205,44,255,0.1)',
border: 'solid 2px rgba(255,66,235,0.3)',
margin: '10px 0 0 0',
color: '#ffffffbf'
}}
>
Warning:&nbsp;Make sure you backup your private key
somewhere safe. If you lose it or it gets leaked, we
actually can't help you.
</p>
<p
className='labelDescriptionMain'
style={{

View File

@ -24,9 +24,7 @@ export const Layout = () => {
const dispatch = useAppDispatch()
const { ndk, fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const { siteWotStatus, siteWotLevel, userWotLevel } = useAppSelector(
(state) => state.wot
)
const { siteWotStatus } = useAppSelector((state) => state.wot)
// calculate site's wot
useEffect(() => {
@ -46,7 +44,7 @@ export const Layout = () => {
})
}
}
}, [ndk, siteWotLevel, dispatch])
}, [dispatch, ndk])
// calculate user's wot
useEffect(() => {
@ -62,7 +60,7 @@ export const Layout = () => {
toast.error('An error occurred in calculating user web-of-trust!')
})
}
}, [ndk, userState.user, userWotLevel, dispatch])
}, [dispatch, ndk, userState.user?.pubkey])
// get site's wot level
useEffect(() => {
@ -72,7 +70,8 @@ export const Layout = () => {
fetchEventFromUserRelays(
{
kinds: [NDKKind.AppSpecificData],
'#d': ['degmods']
'#d': ['degmods'],
authors: [hexPubkey]
},
hexPubkey,
UserRelaysType.Both
@ -94,7 +93,8 @@ export const Layout = () => {
fetchEventFromUserRelays(
{
kinds: [NDKKind.AppSpecificData],
'#d': ['degmods']
'#d': ['degmods'],
authors: [hexPubkey]
},
hexPubkey,
UserRelaysType.Both
@ -106,7 +106,7 @@ export const Layout = () => {
})
}
}
}, [userState.user, dispatch, fetchEventFromUserRelays])
}, [dispatch, fetchEventFromUserRelays, userState.user?.pubkey])
return (
<>

View File

@ -43,14 +43,18 @@ export const SocialNav = () => {
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'
/>
{!!userState.auth && (
<>
<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'

37
src/loaders/comment.ts Normal file
View File

@ -0,0 +1,37 @@
import { NDKContextType } from 'contexts/NDKContext'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { CommentsLoaderResult } from 'types/comments'
import { log, LogType } from 'utils'
export const commentsLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
const { nevent } = params
if (!nevent) {
log(true, LogType.Error, 'Required nevent.')
return redirect('..')
}
try {
const replyEvent = await ndkContext.ndk.fetchEvent(nevent)
if (!replyEvent) {
throw new Error('We are unable to find the comment on the relays')
}
const result: Partial<CommentsLoaderResult> = {
event: replyEvent
}
return result
} catch (error) {
let message = 'An error occurred in fetching comment from relays'
log(true, LogType.Error, message, error)
if (error instanceof Error) {
message = error.message
throw new Error(message)
}
}
return redirect('..')
}

View File

@ -1,4 +1,4 @@
import { Link, useRouteError } from 'react-router-dom'
import { Link, useLocation, useRouteError } from 'react-router-dom'
import { appRoutes } from 'routes'
interface NotFoundPageProps {
@ -12,6 +12,8 @@ export const NotFoundPage = ({
}: Partial<NotFoundPageProps>) => {
const error = useRouteError() as Partial<NotFoundPageProps>
const location = useLocation()
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -23,7 +25,19 @@ export const NotFoundPage = ({
<div>
<p>{error?.message || message}</p>
</div>
<div className='IBMSMAction'>
<div
className='IBMSMAction'
style={{
gap: '10px'
}}
>
<Link
to={location.pathname}
className='btn btnMain IBMSMActionBtn'
type='button'
>
Try again
</Link>
<Link
to={appRoutes.home}
className='btn btnMain IBMSMActionBtn'

Some files were not shown because too many files have changed in this diff Show More