Compare commits

..

137 Commits

Author SHA1 Message Date
59efa91677 pagination optimization
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-18 18:14:59 +00:00
1960589fc3 pagination optimization
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-18 18:12:50 +00:00
0250f6dc11 pagination optimization
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-18 18:09:26 +00:00
dc91a6a186 pagination optimization
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-18 18:04:49 +00:00
daniyal
1d02bf0d6f feat: navigate to search page on submitting search term in games page
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-09-18 22:40:41 +05:00
daniyal
a1dd002d28 feat: enabled routing on game card
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-18 21:49:05 +05:00
daniyal
72cbd325b0 feat: add a game page route that will display all mods associated with that game 2024-09-18 21:49:05 +05:00
daniyal
05414013ce feat: implemented logic for games page 2024-09-18 21:49:05 +05:00
daniyal
c62c1a29b9 chore(refactor): use custom hooks 2024-09-18 21:49:05 +05:00
daniyal
381028614a feat: added a custom hook for games list 2024-09-18 21:49:05 +05:00
876f986ea5 added an extra class to the games search results
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-18 13:53:55 +00:00
b9d5bb8211 Update src/styles/cardMod.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-18 08:03:52 +00:00
daniyal
4dc65b92f7 feat: implemented search page
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-18 08:21:03 +05:00
daniyal
9e8aa16297 chore: fix relay controller 2024-09-18 08:21:03 +05:00
daniyal
a90e932ed6 chore: refactore profile section component 2024-09-18 08:21:03 +05:00
daniyal
9730fec14f chore: move pagination for separate component file 2024-09-18 08:21:03 +05:00
daniyal
06f0282cad chore: memoize modCard 2024-09-18 08:21:03 +05:00
daniyal
5b641ff4cc chore: add error boundary component 2024-09-18 08:21:03 +05:00
49ed027b5c Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-17 19:45:26 +00:00
aa8d18ea53 Update src/styles/innerPage.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-17 19:36:02 +00:00
f708dd6530 css mobile optimizing
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-17 19:35:23 +00:00
e3f49832f2 slider css mobile optimization
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-17 11:52:46 +00:00
d76676c089 added a new class
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-09-17 11:46:24 +00:00
6b1d4e7322 added a div wrapper for mod summary for slider to fix text overflow issue
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-17 11:45:05 +00:00
05adb00072 mobile css optimizing social nav
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-17 11:14:34 +00:00
4175ebc010 css mobile optimizing slides
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-17 10:50:18 +00:00
3a71a4a297 changed default state of social nav
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-09-16 08:43:07 +00:00
daniyal
d3a93eab3e chore: add social nav component in main layout
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-16 12:36:35 +05:00
daniyal
22fc2b4ba3 chore: used Link component from react-router-dom instead of a tag 2024-09-16 12:35:42 +05:00
d70e302a69 Update src/styles/popup.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-12 14:51:44 +00:00
8b93d0506d Update src/styles/cardBlogs.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-12 12:24:00 +00:00
a56d26387e removed inline css in one element
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-09-12 12:17:48 +00:00
d13c7ca6c3 added hover effect
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-12 12:16:15 +00:00
daniyal
34b096b121 chore(refactor): improve subscription process
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-11 22:28:36 +05:00
daniyal
53d47fcb80 fix: improve findUserRelays method in metadata controller 2024-09-11 22:28:36 +05:00
daniyal
7a1d0bbfb0 fix: immediately setHasZapped to true after zapping 2024-09-11 22:28:36 +05:00
daniyal
d15c5a21d9 chore: fix react render error 2024-09-11 22:28:36 +05:00
daniyal
990460d7cf chore: add keys to list elements in home page 2024-09-11 22:28:36 +05:00
96bf84a0c4 added zap icon / numbers to mod box
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-11 15:14:31 +00:00
aa0b9cf3c3 changed class
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-10 15:52:27 +00:00
daniyal
b808157352 fix: added the publish state for comment
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-10 16:36:35 +05:00
511a67b793 typo fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-10 08:40:12 +00:00
5ed6a51e76 added missing class
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-10 08:29:50 +00:00
4d5f132bab seperated the <p>s from each other
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-10 07:54:06 +00:00
50540f0e3f new classes
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-10 07:42:31 +00:00
e31e7d85ac Update src/pages/mod/internal/comment/index.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-09-10 07:18:58 +00:00
daniyal
a3a022c436 feat: implemented comment feature and refactored mod page
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-09-10 11:42:09 +05:00
daniyal
458ad744e4 chore: implemented absolute imports 2024-09-10 11:42:09 +05:00
d77d9f7bcc link change
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-05 13:00:10 +00:00
23ad13fa85 shortcodes "::" fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-05 10:29:22 +00:00
daniyal
87359a914e fix: add signed event to reactionEvents array even before publishing so that UI can be updated immediately
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-05 15:17:51 +05:00
3d20163b08 adjusted and added more emojis/shortcodes
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-05 09:38:15 +00:00
daniyal
0ac31675f9 chore: display reactions UI after reactionEvents are loaded
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-09-05 13:30:10 +05:00
daniyal
98f4666f96 chore: add game name in mod card and featured slider on landing page
All checks were successful
Release to Staging / build_and_release (push) Successful in 52s
2024-09-05 12:46:14 +05:00
daniyal
2dd2992810 feat: implemented the logic for handling reactions on mods 2024-09-05 12:39:52 +05:00
daniyal
a85314f0a7 chore(refactor): reduce code duplication in zap 2024-09-05 12:39:52 +05:00
b12887cdf5 Update src/styles/SimpleSlider.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-03 18:00:37 +00:00
3e3f5fe82b Update src/styles/SimpleSlider.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-03 17:57:32 +00:00
a661b3f781 added game name to game mod slide
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-03 17:56:33 +00:00
1fde36bc5c new class
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-03 15:16:58 +00:00
77d849e3ab added placeholder gamename for a mod card
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-03 15:15:04 +00:00
daniyal
822d5110a8 fix: in setting page display admin tab to only admin users
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-03 16:47:47 +05:00
daniyal
03f9269eb6 fix: fetch mods withoud limit filter on landing page and apply limit once retrived
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-03 15:23:07 +05:00
daniyal
4b51fa55f5 fix: change the route for mod detail page from mods-inner to mod 2024-09-03 15:13:51 +05:00
daniyal
018536e11d fix: sort the mods in by published_at before displaying in latest mods section of landing page
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-03 14:59:23 +05:00
daniyal
c44a28f755 fix: fixed profile picture and bio in profile box
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-03 14:37:54 +05:00
daniyal
8fea6fa27f chore: quick fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-09-03 13:15:47 +05:00
daniyal
fad1ff98b3 feat: implemented logic for profile box
Some checks failed
Release to Staging / build_and_release (push) Failing after 23s
2024-09-03 13:05:37 +05:00
4a7899cfde Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-09-02 18:52:21 +00:00
0c32999df8 Update src/pages/home.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-02 16:04:18 +00:00
b4465ee1c6 Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-09-02 10:23:13 +00:00
b492f97795 Update src/constants.ts
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 10:01:30 +00:00
f13d800d85 Update src/styles/cardMod.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-09-02 09:57:28 +00:00
8726d042f2 Update src/styles/cardMod.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:55:00 +00:00
17ef110f6f wrapped the mod image with a div to fix its presentation
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:53:22 +00:00
764c936ff8 Update src/components/GameCard.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-02 09:49:53 +00:00
1b2926ae77 Update src/components/GameCard.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-09-02 09:48:51 +00:00
9a192451f6 Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:47:55 +00:00
16b3c7684b Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:38:43 +00:00
24ea309dd1 Update src/styles/SimpleSlider.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:27:23 +00:00
41240ee3fb Update src/styles/SimpleSlider.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-09-02 09:04:57 +00:00
098068acef div wrapped the slider image
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 09:03:41 +00:00
daniyal
56ec37e57b feat: display data on landing page
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-09-02 13:50:52 +05:00
daniyal
c6831f3fb2 chore: add a map in relay controller for storing events 2024-09-02 13:50:52 +05:00
daniyal
0733849b25 chore: use fallback images for mod and games if provided image gives error 2024-09-02 13:50:52 +05:00
faf30a89b0 Update src/styles/tags.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-31 12:03:57 +00:00
daniyal
0b2e5c29d5 feat: add image gallery
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-29 23:01:41 +05:00
daniyal
e1da323c2f fix: add fully unmoderated option for admin
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-29 22:13:36 +05:00
s
9893373f75 Merge pull request 'feat: added the ability to unblock the posts' (#26) from unblock into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #26
2024-08-29 13:28:55 +00:00
daniyal
203e27b19d feat: added the ability to unblock the posts 2024-08-29 18:26:06 +05:00
daniyal
47cc4a19ea fix: prepend https:// to url if no protocol is specified
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-29 14:32:27 +05:00
daniyal
ab27a1f9e1 chore: quick fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-29 13:29:01 +05:00
daniyal
8a232c7d91 fix: ability to right-click on a mod post to open in new tab or copy link
Some checks failed
Release to Staging / build_and_release (push) Failing after 22s
2024-08-29 13:24:15 +05:00
daniyal
1e98b16c14 fix: reset body field in mod form when route changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-28 22:57:26 +05:00
d9f0972961 added more report options (2)
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-28 17:40:33 +00:00
aa9884b9fa Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-08-28 17:28:37 +00:00
5cb20794d0 Update src/styles/author.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-28 17:27:02 +00:00
s
4d64c33597 Merge pull request 'feat: added the ability to report and block posts' (#25) from block-report into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #25
2024-08-28 17:05:21 +00:00
daniyal
d9347014ec feat: added the ability to report and block posts 2024-08-28 22:03:43 +05:00
daniyal
1259144228 fix: use published_at for latest/oldest sort
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-27 20:46:42 +05:00
a5018d9a1f Update src/styles/tiptap.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 12:47:24 +00:00
e0440e1638 temp fix targeting div
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-27 12:41:54 +00:00
74e38eac50 Update src/styles/tiptap.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-27 12:27:40 +00:00
eb450839d5 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 12:25:33 +00:00
733c155447 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-27 12:12:27 +00:00
9bdc8678c4 Update src/styles/tiptap.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-27 12:08:50 +00:00
7c2dd9fe7a Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-27 12:03:19 +00:00
9782256483 Update src/styles/tiptap.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-27 11:58:58 +00:00
1cd898eae7 adding classes and adjusting them
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 11:51:28 +00:00
926d29a36e typo fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-27 11:39:04 +00:00
29947757af new class for tiptap btn
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 11:38:13 +00:00
b9b1e1457c added class to toolbar btn
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 11:36:26 +00:00
a88ef61eb7 Update src/styles/tiptap.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-27 11:20:23 +00:00
daniyal
4de54f7688 feat: use tiptap for rich text editor
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-27 15:53:34 +05:00
daniyal
c429dfa322 fix: use @uiw/react-md-editor as rich text editor and displaying markdown
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2024-08-27 00:35:37 +05:00
s
26dcd5463d Merge branch 'master' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-26 13:55:53 +00:00
daniyal
1927887992 fix: reset mod form when route changes from edit to submit
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-26 18:44:00 +05:00
s
dc19e614df Merge pull request 'fix: use naddr instead of nevent' (#22) from naddr into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #22
2024-08-26 12:13:10 +00:00
848af66a75 Merge pull request 'nav/header adjustments' (#21) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
Reviewed-on: #21
2024-08-26 10:28:23 +00:00
aca6908ce9 Update src/styles/nav.module.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-26 10:09:14 +00:00
be7e506457 Update src/styles/nav.module.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-26 09:59:40 +00:00
d9129ab4da Merge pull request 'color adjustments and effects' (#20) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
Reviewed-on: #20
2024-08-21 20:18:14 +00:00
3f873c410c Merge pull request 'color adjustments' (#19) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Reviewed-on: #19
2024-08-21 05:51:00 +00:00
a89a9582fd Merge pull request 'Updated tags in index.html' (#18) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #18
2024-08-20 12:37:56 +00:00
de21af1b4a Merge pull request 'removed comments' (#17) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
Reviewed-on: #17
2024-08-19 14:38:56 +00:00
1a52bfd30b Merge pull request 'temp fix for blockquote style' (#16) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #16
2024-08-19 10:56:41 +00:00
23088cb3ec Merge pull request 'css edit + games list update' (#15) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
Reviewed-on: #15
2024-08-19 09:03:42 +00:00
d8d04b8ae0 Merge pull request 'updated readme and added license' (#14) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
Reviewed-on: #14
2024-08-19 07:36:41 +00:00
s
f8ca32c143 Merge pull request 'fix routing for mod edit page' (#13) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Reviewed-on: #13
2024-08-19 06:42:49 +00:00
s
60317c2e8c Merge pull request 'remove url reachability check for download url as it may give cors error' (#12) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
Reviewed-on: #12
2024-08-19 06:23:59 +00:00
46b0384bc6 Merge pull request 'text and link and style changes,' (#11) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Reviewed-on: #11
2024-08-18 23:36:54 +00:00
99463e3fd2 Merge pull request 'well-known fix' (#10) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #10
2024-08-17 14:04:14 +00:00
00e73aaad8 Merge pull request 'deleted some media files, adjusted some text, added new static elements in the site tip popup, new css classes, and added a well-know with nostr json file for nip-05' (#9) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #9
2024-08-16 19:12:07 +00:00
16b8107731 Merge pull request 'fixed game list issue not appearing in mod submit + temp-fixed its scroll and style ux' (#8) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
Reviewed-on: #8
2024-08-16 15:37:52 +00:00
s
d1f84ddd90 Merge pull request 'Update .gitea/workflows/release-production.yaml' (#7) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Reviewed-on: #7
2024-08-16 12:51:44 +00:00
17e28ede53 Merge pull request 'Mostly text, images, and link changes' (#6) from staging into master
Some checks failed
Release to Staging / build_and_release (push) Failing after 40s
Reviewed-on: #6
2024-08-16 12:36:01 +00:00
s
24203884fb Merge pull request 'chore: add production release workflow' (#5) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
Reviewed-on: #5
2024-08-16 06:22:45 +00:00
69 changed files with 7742 additions and 2968 deletions

View File

@ -1,4 +1,14 @@
# This relay will be used to publish/retrieve events along with other relays (user's relays, admin relays)
VITE_APP_RELAY=wss://relay.degmods.com
# A comma separated list of npubs, Relay list will be extracted for these npubs and this relay list will be used to publish event
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
# A dedicated npub used for reporting mods, blogs, profile and etc.
VITE_REPORTING_NPUB= <npub1...>
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png

View File

@ -24,6 +24,9 @@ jobs:
run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
cat .env
- name: Create Build
@ -34,4 +37,4 @@ jobs:
npm -g install cloudron-surfer
surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com
surfer put dist/* / --all -d
surfer put dist/.well-known / --all
surfer put dist/.well-known / --all

View File

@ -24,6 +24,9 @@ jobs:
run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
cat .env
- name: Create Build

View File

@ -6,7 +6,10 @@
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="DEG Mods - Liberating Game Mods" />
<meta property="og:description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
<meta
property="og:description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<meta property="og:image" content="/assets/img/DEGM%20Thumb.png" />
<meta property="og:url" content="https://degmods.com" />
<meta property="og:type" content="website" />
@ -14,24 +17,33 @@
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
<meta name="twitter:description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
<meta
name="twitter:description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<meta name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
<!-- Other Meta Tags -->
<meta name="description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
<meta
name="description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<!-- Links and Stylesheets -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css" />
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
<link rel="icon" type="image/png" sizes="935x934" href="/assets/img/Logo%20with%20circle.png" />
<link
rel="icon"
type="image/png"
sizes="935x934"
href="/assets/img/Logo%20with%20circle.png"
/>
<title>DEG Mods - Liberating Game Mods</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.js"></script>
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
</body>
</html>

1125
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,17 @@
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6",
"@tiptap/react": "2.6.6",
"@tiptap/starter-kit": "2.6.6",
"axios": "1.7.3",
"bech32": "2.0.0",
"buffer": "6.0.3",
"date-fns": "3.6.0",
"dompurify": "3.1.6",
"file-saver": "2.0.5",
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
@ -27,17 +32,18 @@
"react": "^18.3.1",
"react-countdown": "2.3.5",
"react-dom": "^18.3.1",
"react-quill": "2.0.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",
"uuid": "10.0.0",
"webln": "0.3.2"
},
"devDependencies": {
"@types/dompurify": "3.0.5",
"@types/file-saver": "2.0.7",
"@types/fslightbox-react": "1.7.7",
"@types/lodash": "4.17.7",
"@types/papaparse": "5.3.14",
"@types/react": "^18.3.3",
@ -52,6 +58,7 @@
"eslint-plugin-react-refresh": "^0.4.7",
"ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2",
"vite": "^5.3.1"
"vite": "^5.3.1",
"vite-tsconfig-paths": "5.0.1"
}
}

View File

@ -0,0 +1,3 @@
Game Name,16 by 9 image,Boxart image
Voices of the Void,,https://image.nostr.build/472949882d0756c84d3effd9f641b10c88abd48265f0f01f360937b189d50b54.jpg
Shroom and Gloom,,
1 Game Name 16 by 9 image Boxart image
2 Voices of the Void https://image.nostr.build/472949882d0756c84d3effd9f641b10c88abd48265f0f01f360937b189d50b54.jpg
3 Shroom and Gloom

View File

@ -0,0 +1,4 @@
Game Name,16 by 9 image,Boxart image
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
Vintage Story,,
Yandere Simulator,,
1 Game Name 16 by 9 image Boxart image
2 Minecraft https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
3 Vintage Story
4 Yandere Simulator

View File

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

View File

@ -6,7 +6,7 @@ export const Banner = () => {
<p
className={navStyles.FundingCampaignLink}
>
DEG Mods is currently in pre-alpha (<a href="https://geyser.fund/entry/3335" target="_blank">Learn more</a>).
DEG Mods is currently in pre-alpha (<a href="https://geyser.fund/project/degmods/posts/view/3411" target="_blank">Learn more</a>).
Check out its funding campaign (<a href="https://geyser.fund/project/degmods" target="_blank">Learn more</a>).
</p>
</div>

View File

@ -15,10 +15,6 @@ export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
>
<div
className='cardBlogMainInside'
style={{
background:
'linear-gradient( rgba(255, 255, 255, 0) 0%, #232323 100%)'
}}
>
<h3
style={{

View File

@ -0,0 +1,48 @@
import { Component, ErrorInfo, ReactNode } from 'react'
// Define the state interface for error boundary
interface ErrorBoundaryState {
hasError: boolean
}
// Define the props interface (if you want to pass any props)
interface ErrorBoundaryProps {
children: ReactNode
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
// Update state so the next render will show the fallback UI.
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
return { hasError: true }
}
// Log the error and error info (optional)
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo)
// You could also send the error to a logging service here
console.error('props', this.props)
}
render() {
if (this.state.hasError) {
// You can render any fallback UI here
return (
<div>
<h1>Oops! Something went wrong.</h1>
<p>Please check console.</p>
</div>
)
}
// If no error, render children
return this.props.children
}
}

View File

@ -1,21 +1,31 @@
import { useNavigate } from 'react-router-dom'
import '../styles/cardGames.css'
import { handleGameImageError } from '../utils'
import { getGamePageRoute } from 'routes'
type GameCardProps = {
backgroundLink: string
title: string
imageUrl: string
}
export const GameCard = ({ backgroundLink }: GameCardProps) => {
export const GameCard = ({ title, imageUrl }: GameCardProps) => {
const navigate = useNavigate()
return (
<a className='cardGameMainWrapperLink' href='search.html'>
<div
className='cardGameMain'
style={{
background: `url("${backgroundLink}") center / cover no-repeat`
}}
></div>
<div className='cardGameMainTitle'>
<p>This is a game title, the best game title</p>
<div
className='cardGameMainWrapperLink'
onClick={() => navigate(getGamePageRoute(title))}
>
<div className='cardGameMainWrapper'>
<img
src={imageUrl}
onError={handleGameImageError}
className='cardGameMain'
/>
</div>
</a>
<div className='cardGameMainTitle'>
<p>{title}</p>
</div>
</div>
)
}

View File

@ -1,38 +1,9 @@
import React from 'react'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
import '../styles/customQuillStyles.css'
import Link from '@tiptap/extension-link'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useEffect } from 'react'
import '../styles/styles.css'
const editorFormats = [
'header',
'font',
'size',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'list',
'bullet',
'indent',
'link'
]
const editorModules = {
toolbar: [
[{ header: '1' }, { header: '2' }, { font: [] }],
[{ size: [] }],
['bold', 'italic', 'underline', 'strike', 'blockquote'],
[
{ list: 'ordered' },
{ list: 'bullet' },
{ indent: '-1' },
{ indent: '+1' }
],
['link']
]
}
import '../styles/tiptap.scss'
interface InputFieldProps {
label: string
@ -77,13 +48,9 @@ export const InputField = React.memo(
onChange={handleChange}
></textarea>
) : type === 'richtext' ? (
<ReactQuill
className='inputMain'
formats={editorFormats}
modules={editorModules}
placeholder={placeholder}
value={value}
onChange={(content) => onChange(name, content)}
<RichTextEditor
content={value}
updateContent={(content) => onChange(name, content)}
/>
) : (
<input
@ -138,3 +105,186 @@ export const CheckboxField = React.memo(
</div>
)
)
type RichTextEditorProps = {
content: string
updateContent: (updatedContent: string) => void
}
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
const editor = useEditor({
extensions: [StarterKit, Link],
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
}
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 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().toggleStrike().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: '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' : ''}`}
>
{label}
</button>
)

View File

@ -1,72 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
import '../styles/cardMod.css'
import { handleModImageError } from '../utils'
type ModCardProps = {
title: string
gameName: string
summary: string
backgroundLink: string
handleClick: () => void
imageUrl: string
route: string
}
export const ModCard = ({
title,
summary,
backgroundLink,
handleClick
}: ModCardProps) => {
return (
<a className='cardModMainWrapperLink' onClick={handleClick}>
<div className='cardModMain'>
<div
className='cMMPicture'
style={{
background: `url("${backgroundLink}") center / cover no-repeat`
}}
></div>
<div className='cMMBody'>
<h3 className='cMMBodyTitle'>{title}</h3>
<p className='cMMBodyText'>{summary}</p>
</div>
<div className='cMMFoot'>
<div className='cMMFootReactions'>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
<p>420</p>
export const ModCard = React.memo(
({ title, gameName, summary, imageUrl, route }: ModCardProps) => {
return (
<Link className='cardModMainWrapperLink' to={route}>
<div className='cardModMain'>
<div className='cMMPictureWrapper'>
<img
src={imageUrl}
onError={handleModImageError}
className='cMMPicture'
/>
</div>
<div className='cMMBody'>
<h3 className='cMMBodyTitle'>{title}</h3>
<p className='cMMBodyText'>{summary}</p>
<div className='cMMBodyGame'>
<p>{gameName}</p>
</div>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
<p>420</p>
</div>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p>420</p>
</div>
<div className='cMMFoot'>
<div className='cMMFootReactions'>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
<p>420</p>
</div>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
<p>420</p>
</div>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p>420</p>
</div>
<div className='cMMFootReactionsElement'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
<p>420</p>
</div>
</div>
</div>
</div>
</div>
</a>
)
}
</Link>
)
}
)

View File

@ -1,6 +1,5 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import Papa from 'papaparse'
import React, {
Fragment,
useCallback,
@ -9,11 +8,16 @@ import React, {
useRef,
useState
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { FixedSizeList as List } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { useAppSelector } from '../hooks'
import { T_TAG_VALUE } from '../constants'
import { RelayController } from '../controllers'
import { useAppSelector, useGames } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import {
initializeFormState,
isReachable,
@ -24,12 +28,7 @@ import {
now
} from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs'
import { RelayController } from '../controllers'
import { useNavigate } from 'react-router-dom'
import { getModsInnerPageRoute } from '../routes'
import { DownloadUrl, ModFormState, ModDetails } from '../types'
import { LoadingSpinner } from './LoadingSpinner'
import { T_TAG_VALUE } from '../constants'
interface FormErrors {
game?: string
@ -48,14 +47,14 @@ interface GameOption {
label: string
}
let processedCSV = false
type ModFormProps = {
existingModData?: ModDetails
}
export const ModForm = ({ existingModData }: ModFormProps) => {
const location = useLocation()
const navigate = useNavigate()
const games = useGames()
const userState = useAppSelector((state) => state.user)
const [isPublishing, setIsPublishing] = useState(false)
@ -63,36 +62,21 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
const [formState, setFormState] = useState<ModFormState>(
initializeFormState(existingModData)
)
const [formErrors, setFormErrors] = useState<FormErrors>({})
useEffect(() => {
if (processedCSV) return
processedCSV = true
if (location.pathname === appRoutes.submitMod) {
setFormState(initializeFormState())
}
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
// Fetch the CSV file from the public folder
fetch('/assets/games.csv')
.then((response) => response.text())
.then((csvText) => {
// Parse the CSV text using PapaParse
Papa.parse<{
'Game Name': string
'16 by 9 image': string
'Boxart image': string
}>(csvText, {
worker: true,
header: true,
complete: (results) => {
const options = results.data.map((row) => ({
label: row['Game Name'],
value: row['Game Name']
}))
setGameOptions(options)
}
})
})
.catch((error) => console.error('Error fetching CSV file:', error))
}, [])
useEffect(() => {
const options = games.map((game) => ({
label: game['Game Name'],
value: game['Game Name']
}))
setGameOptions(options)
}, [games])
const handleInputChange = useCallback((name: string, value: string) => {
setFormState((prevState) => ({
@ -274,7 +258,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
relays: publishedOnRelays
})
navigate(getModsInnerPageRoute(naddr))
navigate(getModPageRoute(naddr))
}
setIsPublishing(false)

View File

@ -0,0 +1,138 @@
import React from 'react'
type PaginationProps = {
page: number
disabledNext: boolean
handlePrev: () => void
handleNext: () => void
}
export const Pagination = React.memo(
({ page, disabledNext, handlePrev, handleNext }: PaginationProps) => {
return (
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<button
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={handlePrev}
disabled={page === 1}
>
<i className='fas fa-chevron-left'></i>
</button>
<div className='PaginationMainInsideBoxGroup'>
<button className='PaginationMainInsideBox PMIBActive'>
<p>{page}</p>
</button>
</div>
<button
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={handleNext}
disabled={disabledNext}
>
<i className='fas fa-chevron-right'></i>
</button>
</div>
</div>
</div>
)
}
)
type PaginationWithPageNumbersProps = {
currentPage: number
totalPages: number
handlePageChange: (page: number) => void
}
export const PaginationWithPageNumbers = ({
currentPage,
totalPages,
handlePageChange
}: PaginationWithPageNumbersProps) => {
// Function to render the pagination controls with page numbers
const renderPagination = () => {
const pagesToShow = 5 // Number of page numbers to show around the current page
const pageNumbers: (number | string)[] = [] // Array to store page numbers and ellipses
// Case when the total number of pages is less than or equal to the limit
if (totalPages <= pagesToShow + 2) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i) // Add all pages to the pagination
}
} else {
// Add the first page (always visible)
pageNumbers.push(1)
// Calculate the range of pages to show around the current page
const startPage = Math.max(2, currentPage - Math.floor(pagesToShow / 2))
const endPage = Math.min(
totalPages - 1,
currentPage + Math.floor(pagesToShow / 2)
)
// Add ellipsis if there are pages between the first page and the startPage
if (startPage > 2) pageNumbers.push('...')
// Add the pages around the current page
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i)
}
// Add ellipsis if there are pages between the endPage and the last page
if (endPage < totalPages - 1) pageNumbers.push('...')
// Add the last page (always visible)
pageNumbers.push(totalPages)
}
// Map over the array and render each page number or ellipsis
return pageNumbers.map((page, index) => {
if (typeof page === 'number') {
// For actual page numbers, render clickable boxes
return (
<div
key={index}
className={`PaginationMainInsideBox ${
currentPage === page ? 'PMIBActive' : '' // Highlight the current page
}`}
onClick={() => handlePageChange(page)} // Navigate to the selected page
>
<p>{page}</p>
</div>
)
} else {
// For ellipses, render non-clickable dots
return (
<p key={index} className='PaginationMainInsideBox PMIBDots'>
...
</p>
)
}
})
}
return (
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<div
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={() => handlePageChange(currentPage - 1)}
>
<i className='fas fa-chevron-left'></i>
</div>
<div className='PaginationMainInsideBoxGroup'>
{renderPagination()}
</div>
<div
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={() => handlePageChange(currentPage + 1)}
>
<i className='fas fa-chevron-right'></i>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,110 +1,46 @@
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import {
MetadataController,
RelayController,
UserRelaysType
} from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import { getProfilePageRoute } from '../routes'
import '../styles/author.css'
import '../styles/innerPage.css'
import '../styles/socialPosts.css'
import { UserProfile } from '../types'
import { copyTextToClipboard, log, LogType, now, npubToHex } from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap'
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import _ from 'lodash'
type Props = {
pubkey: string
}
export const ProfileSection = ({ pubkey }: Props) => {
const [profile, setProfile] = useState<UserProfile>()
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(pubkey).then((res) => {
setProfile(res)
})
})
if (!profile) return null
export const ProfileSection = () => {
return (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_Author'>
<div className='IBMSMSMSSS_Author_Top'>
<div className='IBMSMSMSSS_Author_Top_Left'>
<a
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
href='profile.html'
>
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background:
"url('assets/img/DEG%20Mods%20Default%20PP.png') center / cover no-repeat"
}}
></div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>
{author.name}
</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>
{author.handle}
</p>
</div>
</div>
</div>
</a>
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
<p
id='SiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_Address'
>
{author.address}
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
<div
id='copySiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_IconWrapped'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d="M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z"></path>
</svg>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d="M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z"></path>
</svg>
</div>
</div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
<div
id='OwnerFollowLogin'
className='IBMSMSMSSS_Author_Top_NostrLinks'
style={{ display: 'flex' }}
></div>
</div>
</div>
<button className='btn btnMain' type='button'>
Follow
</button>
</div>
<Profile profile={profile} />
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
@ -154,18 +90,115 @@ export const ProfileSection = () => {
)
}
interface Author {
name: string
handle: string
address: string
bio: string
type ProfileProps = {
profile: NDKUserProfile
}
const author: Author = {
name: 'User name',
handle: 'nip5handle@domain.com',
address: 'npub1address',
bio: `user bio, this is a long string of temporary text that would be replaced with the user bio from their metada address`
export const Profile = ({ profile }: ProfileProps) => {
const handleCopy = async () => {
copyTextToClipboard(profile.npub as string).then((isCopied) => {
if (isCopied) {
toast.success('Npub copied to clipboard!')
} else {
toast.error(
'Failed to copy, look into console for more details on error!'
)
}
})
}
const hexPubkey = npubToHex(profile.pubkey as string)
if (!hexPubkey) return null
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: hexPubkey
})
)
const npub = (profile.npub as string) || ''
const displayName =
profile.displayName ||
profile.name ||
_.truncate(npub, {
length: 16
})
const nip05 = profile.nip05 || ''
const about = profile.bio || profile.about || ''
return (
<div className='IBMSMSMSSS_Author'>
<div className='IBMSMSMSSS_Author_Top'>
<div className='IBMSMSMSSS_Author_Top_Left'>
<Link
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
to={profileRoute}
>
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `url('${
profile.image || ''
}') center / cover no-repeat`
}}
></div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
</div>
</div>
</div>
</Link>
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
<p
id='SiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_Address'
>
{npub}
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
<div
id='copySiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={handleCopy}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
<QRButtonWithPopUp pubkey={hexPubkey} />
<ZapButtonWithPopUp pubkey={hexPubkey} />
</div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>{about}</p>
<div
id='OwnerFollowLogin'
className='IBMSMSMSSS_Author_Top_NostrLinks'
style={{ display: 'flex' }}
></div>
</div>
</div>
<FollowButton pubkey={hexPubkey} />
</div>
)
}
interface Post {
@ -193,3 +226,314 @@ const posts: Post[] = [
imageUrl: '/assets/img/DEGMods%20Placeholder%20Img.png'
}
]
type QRButtonWithPopUpProps = {
pubkey: string
}
const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
const nprofile = nip19.nprofileEncode({
pubkey
})
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
const a = document.createElement('a')
a.href = href
a.target = '_blank' // Open in a new tab
a.rel = 'noopener noreferrer' // Recommended for security reasons
a.click()
}
return (
<>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
{isOpen && (
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Nostr Address</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => setIsOpen(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='popUpMainCardBottom'>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={nprofile}
height={235}
width={235}
/>
</div>
</div>
</div>
</div>
</div>
)}
</>
)
}
type ZapButtonWithPopUpProps = {
pubkey: string
}
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
{isOpen && (
<ZapPopUp
title='Tip/Zap'
receiver={pubkey}
handleClose={() => setIsOpen(false)}
/>
)}
</>
)
}
type FollowButtonProps = {
pubkey: string
}
const FollowButton = ({ pubkey }: FollowButtonProps) => {
const [isFollowing, setIsFollowing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const userState = useAppSelector((state) => state.user)
useDidMount(async () => {
if (userState.auth && userState.user?.pubkey) {
const userHexKey = userState.user.pubkey as string
const { isFollowing: isAlreadyFollowing } = await checkIfFollowing(
userHexKey,
pubkey
)
setIsFollowing(isAlreadyFollowing)
}
})
const getUserPubKey = async (): Promise<string | null> => {
if (userState.auth && userState.user?.pubkey) {
return userState.user.pubkey as string
} else {
return (await window.nostr?.getPublicKey()) as string
}
}
const checkIfFollowing = async (
userHexKey: string,
pubkey: string
): Promise<{
isFollowing: boolean
tags: string[][]
}> => {
const filter: Filter = {
kinds: [kinds.Contacts],
authors: [userHexKey]
}
const contactListEvent =
await RelayController.getInstance().fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Both
)
if (!contactListEvent)
return {
isFollowing: false,
tags: []
}
return {
isFollowing: contactListEvent.tags.some(
(t) => t[0] === 'p' && t[1] === pubkey
),
tags: contactListEvent.tags
}
}
const signAndPublishEvent = async (
unsignedEvent: UnsignedEvent
): Promise<boolean> => {
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) return false
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
return false
}
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
return true
}
const handleFollow = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Processing follow request')
const userHexKey = await getUserPubKey()
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return
}
const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing(
userHexKey,
pubkey
)
if (isAlreadyFollowing) {
toast.info('Already following!')
setIsFollowing(true)
setIsLoading(false)
return
}
const unsignedEvent: UnsignedEvent = {
content: '',
created_at: now(),
kind: kinds.Contacts,
pubkey: userHexKey,
tags: [...tags, ['p', pubkey]]
}
setLoadingSpinnerDesc('Signing and publishing follow event')
const success = await signAndPublishEvent(unsignedEvent)
setIsFollowing(success)
setIsLoading(false)
}
const handleUnFollow = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Processing unfollow request')
const userHexKey = await getUserPubKey()
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return
}
const filter: Filter = {
kinds: [kinds.Contacts],
authors: [userHexKey]
}
const contactListEvent =
await RelayController.getInstance().fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Both
)
if (
!contactListEvent ||
!contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey)
) {
// could not found target pubkey in user's follow list
// so, just update the status and return
setIsFollowing(false)
setIsLoading(false)
return
}
const unsignedEvent: UnsignedEvent = {
content: '',
created_at: now(),
kind: kinds.Contacts,
pubkey: userHexKey,
tags: contactListEvent.tags.filter(
(t) => !(t[0] === 'p' && t[1] === pubkey)
)
}
setLoadingSpinnerDesc('Signing and publishing unfollow event')
const success = await signAndPublishEvent(unsignedEvent)
setIsFollowing(!success)
setIsLoading(false)
}
return (
<>
<button
className='btn btnMain'
type='button'
onClick={isFollowing ? handleUnFollow : handleFollow}
>
{isFollowing ? 'Un-Follow' : 'Follow'}
</button>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}

View File

@ -1,12 +1,25 @@
import { QRCodeSVG } from 'qrcode.react'
import React, { Dispatch, SetStateAction, useMemo } from 'react'
import React, {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useMemo,
useState
} from 'react'
import Countdown, { CountdownRenderProps } from 'react-countdown'
import { toast } from 'react-toastify'
import { ZapController } from '../controllers'
import { useDidMount } from '../hooks'
import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import '../styles/popup.css'
import { PaymentRequest } from '../types'
import { copyTextToClipboard } from '../utils'
import {
copyTextToClipboard,
formatNumber,
getZapAmount,
unformatNumber
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
type PresetAmountProps = {
label: string
@ -106,15 +119,28 @@ type ZapQRProps = {
paymentRequest: PaymentRequest
handleClose: () => void
handleQRExpiry: () => void
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
}
export const ZapQR = React.memo(
({ paymentRequest, handleClose, handleQRExpiry }: ZapQRProps) => {
({
paymentRequest,
handleClose,
handleQRExpiry,
setTotalZapAmount,
setHasZapped
}: ZapQRProps) => {
useDidMount(() => {
ZapController.getInstance()
.pollZapReceipt(paymentRequest)
.then(() => {
.then((zapReceipt) => {
toast.success(`Successfully sent sats!`)
if (setTotalZapAmount) {
const amount = getZapAmount(zapReceipt)
setTotalZapAmount((prev) => prev + amount)
if (setHasZapped) setHasZapped(true)
}
})
.catch((err) => {
toast.error(err.message || err)
@ -194,3 +220,243 @@ const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
</div>
)
})
type ZapPopUpProps = {
title: string
labelDescriptionMain?: ReactNode
receiver: string
eventId?: string
aTag?: string
notCloseAfterZap?: boolean
lastNode?: ReactNode
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
handleClose: () => void
}
export const ZapPopUp = ({
title,
labelDescriptionMain,
receiver,
eventId,
aTag,
lastNode,
notCloseAfterZap,
setTotalZapAmount,
setHasZapped,
handleClose
}: ZapPopUpProps) => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
const [message, setMessage] = useState('')
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const unformattedValue = unformatNumber(event.target.value)
setAmount(unformattedValue)
}
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return null
}
setLoadingSpinnerDesc('finding receiver metadata')
const metadataController = await MetadataController.getInstance()
const receiverMetadata = await metadataController.findMetadata(receiver)
if (!receiverMetadata?.lud16) {
setIsLoading(false)
toast.error('Lighting address (lud16) is missing in receiver metadata!')
return null
}
if (!receiverMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in receiver metadata!')
return null
}
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
return await zapController
.getLightningPaymentRequest(
receiverMetadata.lud16,
amount,
receiverMetadata.pubkey as string,
userHexKey,
message,
eventId,
aTag
)
.catch((err) => {
toast.error(err.message || err)
return null
})
.finally(() => {
setIsLoading(false)
})
}, [amount, message, userState, receiver, eventId, aTag])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setPaymentRequest(pr)
}
const handleSend = useCallback(async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setIsLoading(true)
setLoadingSpinnerDesc('Sending payment!')
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
await zapController
.sendPayment(pr.pr)
.then(() => {
toast.success(`Successfully sent ${amount} sats!`)
if (setTotalZapAmount) {
setTotalZapAmount((prev) => prev + amount)
if (setHasZapped) setHasZapped(true)
}
if (!notCloseAfterZap) {
handleClose()
}
})
.catch((err) => {
toast.error(err.message || err)
})
} else {
toast.warn('Webln is not present. Use QR code to send zap.')
setPaymentRequest(pr)
}
setIsLoading(false)
}, [
amount,
notCloseAfterZap,
handleClose,
generatePaymentRequest,
setTotalZapAmount,
setHasZapped
])
const handleQRExpiry = useCallback(() => {
setPaymentRequest(undefined)
}, [])
const handleQRClose = useCallback(() => {
setPaymentRequest(undefined)
setIsLoading(false)
if (!notCloseAfterZap) {
handleClose()
}
}, [notCloseAfterZap, handleClose])
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{title}</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='pUMCB_ZapsInsideAmount'>
<div className='inputLabelWrapperMain'>
{labelDescriptionMain}
<label className='form-label labelMain'>
Amount (Satoshis)
</label>
<input
className='inputMain'
type='text'
inputMode='numeric'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Message (optional)
</label>
<input
type='text'
className='inputMain'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleQRClose}
handleQRExpiry={handleQRExpiry}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
/>
)}
{lastNode}
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -1,2 +1,135 @@
export const T_TAG_VALUE = 'GameMod'
export const MOD_FILTER_LIMIT = 20
export const LANDING_PAGE_DATA = {
featuredSlider: [
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5cek8pnrwc34xgknyv33xqkngc34xyknscfjxsknzvp38quxgc33vejnqvqhqecq8',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vpcxs6nwwp3x5knyd3evckngetxxcknjdfkx5kngdfhvgukvwfjxsunseqnend73',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5dp4xsex2e3cxuknsdryvvkngc3sxcknjef4vcknvvmyvcukyd3kvd3rxdgnuver5',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vf5x9nrxcekxvknjvmzxvkngcfsx5kkzcf3xqknsvmrvgenwe3j8p3nzwgka59vj'
],
awesomeMods: [
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5df5xccngvtrxqkkydpexukngvp4xgknsvp4vskkgdrxvgmkxdmp8quxycgx78rpf',
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vrrvgmnjc33xuknwde4vskngvekxgknsenyxvkk2ctxvscrvenpvsmnxeqydygjx'
],
featuredGames: [
{
title: 'SUPERHOT',
imageUrl: ''
},
{
title: 'The Bounce House',
imageUrl: ''
},
{
title: 'Immortal Guns',
imageUrl: ''
},
{
title: 'Magenta Horizon Act 1',
imageUrl: ''
},
{
title: 'DEAD LETTER DEPT. Demo',
imageUrl: ''
}
]
}
// we use this object to check if a user has reacted positively or negatively to a post
// reactions are kind 7 events and their content is either emoji icon or emoji shortcode
// Extend the following object as per need to include more emojis and shortcodes
// NOTE: In following object emojis and shortcode array are not interlinked.
// Both of these arrays can have separate items
export const REACTIONS = {
positive: {
emojis: [
'+',
'❤️',
'💙',
'💖',
'💚',
'⭐',
'🚀',
'🫂',
'🎉',
'🥳',
'🎊',
'👍',
'💪',
'😎'
],
shortCodes: [
':red_heart:',
':blue_heart:',
':sparkling_heart:',
':green_heart:',
':star:',
':rocket:',
':people_hugging:',
':party_popper:',
':tada:',
':partying_face:',
':confetti_ball:',
':thumbs_up:',
':+1:',
':thumbsup:',
':thumbup:',
':flexed_biceps:',
':muscle:'
]
},
negative: {
emojis: [
'-',
'💩',
'💔',
'👎',
'😠',
'😞',
'🤬',
'🤢',
'🤮',
'🖕',
'😡',
'💢',
'😠',
'💀'
],
shortCodes: [
':poop:',
':shit:',
':poo:',
':hankey:',
':pile_of_poo:',
':broken_heart:',
':thumbsdown:',
':thumbdown:',
':nauseated_face:',
':sick:',
':face_vomiting:',
':vomiting_face:',
':face_with_open_mouth_vomiting:',
':middle_finger:',
':rage:',
':anger:',
':anger_symbol:',
':angry_face:',
':angry:',
':smiling_face_with_sunglasses:',
':sunglasses:',
':skull:',
':skeleton:'
]
}
}
// NOTE: there should be a corresponding CSV file in public/assets/games folder for each entry in the array
export const GAME_FILES = [
'Games_Itch.csv',
'Games_Other.csv',
'Games_Steam.csv'
]
export const MAX_MODS_PER_PAGE = 10
export const MAX_GAMES_PER_PAGE = 10

View File

@ -2,7 +2,7 @@ import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk'
import { kinds } from 'nostr-tools'
import { MuteLists } from '../types'
import { UserProfile } from '../types/user'
import { hexToNpub, log, LogType, npubToHex } from '../utils'
import { hexToNpub, log, LogType, npubToHex, timeout } from '../utils'
export enum UserRelaysType {
Read = 'readRelayUrls',
@ -19,6 +19,7 @@ export class MetadataController {
private usersMetadata = new Map<string, UserProfile>()
public adminNpubs: string[]
public adminRelays = new Set<string>()
public reportingNpub: string
private constructor() {
this.ndk = new NDK({
@ -40,6 +41,7 @@ export class MetadataController {
})
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB
}
private setAdminRelays = async () => {
@ -119,48 +121,130 @@ export class MetadataController {
public findUserRelays = async (
hexKey: string,
userRelaysType: UserRelaysType = UserRelaysType.Both
) => {
const ndkRelayList = await getRelayListForUser(hexKey, this.ndk)
): Promise<string[]> => {
log(true, LogType.Info, ` Finding user's relays`, hexKey, userRelaysType)
if (!ndkRelayList) {
throw new Error(`Couldn't found user's relay list`)
}
const ndkRelayListPromise = getRelayListForUser(hexKey, this.ndk)
return ndkRelayList[userRelaysType]
// Use Promise.race to either get the NDKRelayList instance or handle the timeout
return await Promise.race([
ndkRelayListPromise,
timeout() // Custom timeout function that rejects after a specified time
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
log(true, LogType.Error, err)
return [] // Return an empty array if an error occurs
})
}
public getAdminsMuteLists = async (): Promise<MuteLists> => {
// Create a Set to collect all unique muted authors
public getMuteLists = async (
pubkey?: string
): Promise<{
admin: MuteLists
user: MuteLists
}> => {
const adminMutedAuthors = new Set<string>()
const adminMutedPosts = new Set<string>()
const mutedAuthors = new Set<string>()
// Create an array of promises to fetch mute lists for each npub
const promises = this.adminNpubs.map(async (npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return
const adminHexKey = npubToHex(this.reportingNpub)
if (adminHexKey) {
const muteListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Mutelist],
authors: [hexKey]
authors: [adminHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
// Add muted authors to the Set directly
if (item[0] === 'p') {
mutedAuthors.add(item[1])
adminMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
adminMutedPosts.add(item[1])
}
})
}
})
}
await Promise.allSettled(promises)
const userMutedAuthors = new Set<string>()
const userMutedPosts = new Set<string>()
if (pubkey) {
const userHexKey = npubToHex(pubkey)
if (userHexKey) {
const muteListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Mutelist],
authors: [userHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
if (item[0] === 'p') {
userMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
userMutedPosts.add(item[1])
}
})
}
}
}
return {
authors: Array.from(mutedAuthors),
eventIds: []
admin: {
authors: Array.from(adminMutedAuthors),
replaceableEvents: Array.from(adminMutedPosts)
},
user: {
authors: Array.from(userMutedAuthors),
replaceableEvents: Array.from(userMutedPosts)
}
}
}
/**
* Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin.
*
* @returns {Promise<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
*/
public getNSFWList = async (): Promise<string[]> => {
// Initialize an array to store the NSFW post identifiers
const nsfwPosts: string[] = []
// Convert the public key (npub) to a hexadecimal format
const hexKey = npubToHex(this.reportingNpub)
// If the conversion is successful and we have a hexKey
if (hexKey) {
// Fetch the event that contains the NSFW list
const nsfwListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Curationsets],
authors: [hexKey],
'#d': ['nsfw']
})
if (nsfwListEvent) {
// Convert the event data to an NDKList, which is a structured list format
const list = NDKList.from(nsfwListEvent)
// Iterate through the items in the list
list.items.forEach((item) => {
if (item[0] === 'a') {
// Add the identifier of the NSFW post to the nsfwPosts array
nsfwPosts.push(item[1])
}
})
}
}
// Return the array of NSFW post identifiers
return nsfwPosts
}
}

View File

@ -1,4 +1,4 @@
import { Event, Filter, kinds, Relay } from 'nostr-tools'
import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools'
import {
extractZapAmount,
log,
@ -7,13 +7,13 @@ import {
timeout
} from '../utils'
import { MetadataController, UserRelaysType } from './metadata'
import { ModDetails } from '../types'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
private events = new Map<string, Event>()
private debug = true
public connectedRelays: Relay[] = []
@ -61,7 +61,116 @@ export class RelayController {
/**
* Publishes an event to multiple relays.
*
* This method connects to the application relay and a set of write relays
* This method establishes a connection to the application relay specified by
* an environment variable and a set of relays obtained from the
* `MetadataController`. It attempts to publish the event to all connected
* relays and returns a list of URLs of relays where the event was successfully
* published.
*
* If the process of finding relays or publishing the event takes too long,
* it handles the timeout to prevent blocking the operation.
*
* @param event - The event to be published.
* @param userHexKey - The user's hexadecimal public key, used to retrieve their relays.
* If not provided, the event's public key will be used.
* @param userRelaysType - The type of relays to be retrieved (e.g., write relays).
* Defaults to `UserRelaysType.Write`.
* @returns A promise that resolves to an array of URLs of relays where the event
* was published, or an empty array if no relays were connected or the
* event could not be published.
*/
publish = async (
event: Event,
userHexKey?: string,
userRelaysType?: UserRelaysType
): Promise<string[]> => {
// Connect to the application relay specified by an environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
// TODO: Implement logic to retrieve relays using `window.nostr.getRelays()` once it becomes available in nostr-login.
// Retrieve an instance of MetadataController to find user relays
const metadataController = await MetadataController.getInstance()
// Retrieve the list of relays for the specified user's public key
const relayUrls = await metadataController.findUserRelays(
userHexKey || event.pubkey,
userRelaysType || UserRelaysType.Write
)
// Add admin relay URLs from the metadata controller to the list of relay URLs
metadataController.adminRelays.forEach((url) => {
relayUrls.push(url)
})
// Attempt to connect to all write relays obtained from MetadataController
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Wait for all relay connection attempts to settle (either fulfilled or rejected)
await Promise.allSettled([appRelayPromise, ...relayPromises])
// If no relays are connected, log an error and return an empty array
if (this.connectedRelays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!')
return []
}
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
// Create promises to publish the event to each connected relay
const publishPromises = this.connectedRelays.map((relay) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Sending event:`,
event
)
return Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle slow publishing operations
])
.then((res) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Publish result:`,
res
)
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relay.url}): Publish error!`,
err
)
})
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
if (publishedOnRelays.length > 0) {
// If the event was successfully published to any relays, check if it contains an `aTag`
// If the `aTag` is present, cache the event locally
const aTag = event.tags.find((item) => item[0] === 'a')
if (aTag && aTag[1]) {
this.events.set(aTag[1], event)
}
}
// Return the list of relay URLs where the event was successfully published
return publishedOnRelays
}
/**
* Publishes an encrypted DM to receiver's read relays.
*
* This method connects to the application relay and a set of receiver's read relays
* obtained from the `MetadataController`. It then publishes the event to
* all connected relays and returns a list of relays where the event was successfully published.
*
@ -69,7 +178,7 @@ export class RelayController {
* @returns A promise that resolves to an array of URLs of relays where the event was published,
* or an empty array if no relays were connected or the event could not be published.
*/
publish = async (event: Event): Promise<string[]> => {
publishDM = async (event: Event, receiver: string): Promise<string[]> => {
// Connect to the application relay specified by environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
@ -77,31 +186,19 @@ export class RelayController {
const metadataController = await MetadataController.getInstance()
// Retrieve the list of write relays for the event's public key
// Use a timeout to handle cases where retrieving write relays takes too long
const writeRelaysPromise = metadataController.findUserRelays(
event.pubkey,
UserRelaysType.Write
// Retrieve the list of read relays for the receiver
const readRelayUrls = await metadataController.findUserRelays(
receiver,
UserRelaysType.Read
)
log(this.debug, LogType.Info, ` Finding user's write relays`)
// Use Promise.race to either get the write relay URLs or timeout
const writeRelayUrls = await Promise.race([
writeRelaysPromise,
timeout() // This is a custom timeout function that rejects the promise after a specified time
]).catch((err) => {
log(this.debug, LogType.Error, err)
return [] as string[] // Return an empty array if an error occurs
})
// push admin relay urls obtained from metadata controller to writeRelayUrls list
// push admin relay urls obtained from metadata controller to readRelayUrls list
metadataController.adminRelays.forEach((url) => {
writeRelayUrls.push(url)
readRelayUrls.push(url)
})
// Connect to all write relays obtained from MetadataController
const relayPromises = writeRelayUrls.map((relayUrl) =>
const relayPromises = readRelayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
@ -155,6 +252,112 @@ export class RelayController {
return publishedOnRelays
}
/**
* Publishes an event to multiple relays.
*
* This method establishes a connection to the application relay specified by
* an environment variable and a set of relays provided as argument.
* It attempts to publish the event to all connected relays
* and returns a list of URLs of relays where the event was successfully published.
*
* If the process of publishing the event takes too long,
* it handles the timeout to prevent blocking the operation.
*
* @param event - The event to be published.
* @param relayUrls - The array of relayUrl where event should be published
* @returns A promise that resolves to an array of URLs of relays where the event
* was published, or an empty array if no relays were connected or the
* event could not be published.
*/
publishOnRelays = async (
event: Event,
relayUrls: string[]
): Promise<string[]> => {
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!')
return []
}
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
// Create promises to publish the event to each connected relay
const publishPromises = this.connectedRelays.map((relay) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Sending event:`,
event
)
return Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle slow publishing operations
])
.then((res) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Publish result:`,
res
)
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relay.url}): Publish error!`,
err
)
})
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
if (publishedOnRelays.length > 0) {
// If the event was successfully published to any relays, check if it contains an `aTag`
// If the `aTag` is present, cache the event locally
const aTag = event.tags.find((item) => item[0] === 'a')
if (aTag && aTag[1]) {
this.events.set(aTag[1], event)
}
}
// Return the list of relay URLs where the event was successfully published
return publishedOnRelays
}
/**
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
@ -165,24 +368,46 @@ export class RelayController {
*/
fetchEvents = async (
filter: Filter,
relays: string[] = []
relayUrls: string[] = []
): Promise<Event[]> => {
// add app relay to relays array
relays.push(import.meta.env.VITE_APP_RELAY)
const relaySet = new Set<string>()
// add all the relays passed to relay set
relayUrls.forEach((relayUrl) => {
relaySet.add(relayUrl)
})
relaySet.add(import.meta.env.VITE_APP_RELAY)
const metadataController = await MetadataController.getInstance()
// add admin relays to relays array
metadataController.adminRelays.forEach((url) => {
relays.push(url)
metadataController.adminRelays.forEach((relayUrl) => {
relaySet.add(relayUrl)
})
relayUrls = Array.from(relaySet)
// Connect to all specified relays
const relayPromises = relays.map((relayUrl) => this.connectRelay(relayUrl))
await Promise.allSettled(relayPromises)
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (this.connectedRelays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected to fetch events!')
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
@ -190,7 +415,7 @@ export class RelayController {
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = this.connectedRelays.map((relay) => {
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
@ -238,92 +463,287 @@ export class RelayController {
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
// first check if event is present in cached map then return that
// otherwise query relays
if (filter['#a']) {
const aTag = filter['#a'][0]
const cachedEvent = this.events.get(aTag)
if (cachedEvent) return cachedEvent
}
const events = await this.fetchEvents(filter, relays)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
// Return the most recent event, or null if no events were received
return events[0] || null
if (events.length > 0) {
const event = events[0]
// if the aTag was specified in filter then cache the fetched event before returning
if (filter['#a']) {
const aTag = filter['#a'][0]
this.events.set(aTag, event)
}
// return the event
return event
}
// return null if event array is empty
return null
}
/**
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves with an array of events.
*/
fetchEventsFromUserRelays = async (
filter: Filter,
hexKey: string,
userRelaysType: UserRelaysType
): Promise<Event[]> => {
// Get an instance of the MetadataController, which manages user metadata and relays
const metadataController = await MetadataController.getInstance()
// Find the user's relays using the MetadataController.
const relayUrls = await metadataController.findUserRelays(
hexKey,
userRelaysType
)
// Fetch the event from the user's relays using the provided filter and relay URLs
return this.fetchEvents(filter, relayUrls)
}
/**
* Fetches an event from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves to the fetched event or null if the operation fails.
*/
fetchEventFromUserRelays = async (
filter: Filter,
hexKey: string,
userRelaysType: UserRelaysType
): Promise<Event | null> => {
// first check if event is present in cached map then return that
// otherwise query relays
if (filter['#a']) {
const aTag = filter['#a'][0]
const cachedEvent = this.events.get(aTag)
if (cachedEvent) return cachedEvent
}
const events = await this.fetchEventsFromUserRelays(
filter,
hexKey,
userRelaysType
)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
if (events.length > 0) {
const event = events[0]
// if the aTag was specified in filter then cache the fetched event before returning
if (filter['#a']) {
const aTag = filter['#a'][0]
this.events.set(aTag, event)
}
// return the event
return event
}
// return null if event array is empty
return null
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`APP_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subscriptions = relays.map((relay) =>
relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
}
})
)
return subscriptions
}
getTotalZapAmount = async (
modDetails: ModDetails,
user: string,
eTag: string,
aTag?: string,
currentLoggedInUser?: string
) => {
const metadataController = await MetadataController.getInstance()
const authorReadRelaysPromise = metadataController.findUserRelays(
modDetails.author,
const relayUrls = await metadataController.findUserRelays(
user,
UserRelaysType.Read
)
log(this.debug, LogType.Info, ` Finding user's read relays`)
// Use Promise.race to either get the write relay URLs or timeout
const relayUrls = await Promise.race([
authorReadRelaysPromise,
timeout() // This is a custom timeout function that rejects the promise after a specified time
]).catch((err) => {
log(this.debug, LogType.Error, err)
return [] as string[] // Return an empty array if an error occurs
})
// add app relay to relays array
relayUrls.push(import.meta.env.VITE_APP_RELAY)
// add admin relays to relays array
metadataController.adminRelays.forEach((url) => {
relayUrls.push(url)
})
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
relayUrls.push(appRelay)
}
// Connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
await Promise.allSettled(relayPromises)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
let accumulatedZapAmount = 0
let hasZapped = false
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
const filters: Filter[] = [
{
kinds: [kinds.Zap],
'#e': [eTag]
}
]
if (aTag) {
filters.push({
kinds: [kinds.Zap],
'#a': [aTag]
})
}
// Create a promise for each relay subscription
const subPromises = this.connectedRelays.map((relay) => {
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe(
[
{
kinds: [kinds.Zap],
'#a': [modDetails.aTag]
}
],
{
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
console.log('e :>> ', e)
eventIds.add(e.id) // Record the event ID
const amount = extractZapAmount(e)
accumulatedZapAmount += amount
const sub = relay.subscribe(filters, {
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
eventIds.add(e.id) // Record the event ID
const zapRequestStr = e.tags.find(
(t) => t[0] === 'description'
)?.[1]
if (!zapRequestStr) return
const error = nip57.validateZapRequest(zapRequestStr)
if (error) return
let zapRequest: Event | null = null
try {
zapRequest = JSON.parse(zapRequestStr)
} catch (error) {
log(
true,
LogType.Error,
'Error occurred in parsing zap request',
error
)
}
if (!zapRequest) return
const amount = extractZapAmount(zapRequest)
accumulatedZapAmount += amount
if (amount > 0) {
if (!hasZapped) {
hasZapped =
e.tags.findIndex(
(tag) => tag[0] === 'P' && tag[1] === currentLoggedInUser
) > -1
hasZapped = zapRequest.pubkey === currentLoggedInUser
}
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
)
})
})
})

View File

@ -12,6 +12,7 @@ import {
} from '../types'
import { log, LogType, npubToHex } from '../utils'
import { RelayController } from './relay'
import { MetadataController, UserRelaysType } from './metadata'
/**
* Singleton class to manage zap related operations.
@ -147,7 +148,7 @@ export class ZapController {
const cleanup = () => {
clearTimeout(timeout)
sub.close()
subscriptions.forEach((subscription) => subscription.close())
}
// Polling timeout
@ -160,13 +161,11 @@ export class ZapController {
pollingTimeout || 6 * 60 * 1000 // 6 minutes
)
const relay = await RelayController.getInstance().connectRelay(
this.appRelay
)
const relaysTag = zapRequest.tags.find((t) => t[0] === 'relays')
if (!relaysTag)
throw new Error('Zap request does not contain relays tag.')
if (!relay) {
return reject('Polling Zap Receipt: Could not connect to app relay!')
}
const relayUrls = relaysTag.slice(1)
// filter relay for event of kind 9735
const filter: Filter = {
@ -174,25 +173,27 @@ export class ZapController {
since: created_at
}
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: async (event) => {
// get description tag of the event
const description = event.tags.filter(
(tag) => tag[0] === 'description'
)[0]
const subscriptions =
await RelayController.getInstance().subscribeForEvents(
filter,
relayUrls,
async (event) => {
// get description tag of the event
const description = event.tags.filter(
(tag) => tag[0] === 'description'
)[0]
// compare description tag of the event with stringified zap request
if (description[1] === zapRequestStringified) {
// validate zap receipt
if (await this.validateZapReceipt(pr, event as ZapReceipt)) {
cleanup()
// compare description tag of the event with stringified zap request
if (description[1] === zapRequestStringified) {
// validate zap receipt
if (await this.validateZapReceipt(pr, event as ZapReceipt)) {
cleanup()
resolve(event as ZapReceipt)
resolve(event as ZapReceipt)
}
}
}
}
})
)
})
}
@ -280,11 +281,21 @@ export class ZapController {
if (!recipientHexKey) throw 'Invalid recipient pubKey.'
const metadataController = await MetadataController.getInstance()
const receiverReadRelays = await metadataController.findUserRelays(
recipientHexKey,
UserRelaysType.Read
)
if (!receiverReadRelays.includes(this.appRelay)) {
receiverReadRelays.push(this.appRelay)
}
const zapRequest: ZapRequest = {
kind: kinds.ZapRequest,
content,
tags: [
['relays', `${this.appRelay}`],
['relays', ...receiverReadRelays],
['amount', `${amount}`],
['p', recipientHexKey]
],

View File

@ -1,2 +1,5 @@
export * from './redux'
export * from './useDidMount'
export * from './useGames'
export * from './useMuteLists'
export * from './useReactions'

81
src/hooks/useGames.ts Normal file
View File

@ -0,0 +1,81 @@
import { GAME_FILES } from 'constants.ts'
import Papa from 'papaparse'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { Game } from 'types'
import { log, LogType } from 'utils'
export const useGames = () => {
const hasProcessedFiles = useRef(false)
const [games, setGames] = useState<Game[]>([])
useEffect(() => {
if (hasProcessedFiles.current) return
hasProcessedFiles.current = true
const readGamesCSVs = async () => {
const uniqueGames: Game[] = []
const gameNames = new Set<string>()
// Function to promisify PapaParse
const parseCSV = (csvText: string) =>
new Promise<Game[]>((resolve, reject) => {
Papa.parse<Game>(csvText, {
worker: true,
header: true,
complete: (results) => {
if (results.errors.length) {
reject(results.errors)
}
resolve(results.data)
}
})
})
try {
// Fetch and parse each file
const promises = GAME_FILES.map(async (filename) => {
const response = await fetch(`/assets/games/${filename}`)
const csvText = await response.text()
const parsedGames = await parseCSV(csvText)
// Remove duplicate games based on 'Game Name'
parsedGames.forEach((game) => {
if (!gameNames.has(game['Game Name'])) {
gameNames.add(game['Game Name'])
uniqueGames.push(game)
}
})
})
await Promise.all(promises)
setGames(uniqueGames)
} catch (err) {
log(
true,
LogType.Error,
'An error occurred in reading and parsing games CSVs',
err
)
// Handle the unknown error type
if (err instanceof Error) {
toast.error(err.message)
} else if (Array.isArray(err) && err.length > 0 && err[0]?.message) {
// Handle the case when it's an array of PapaParse errors
toast.error(err[0].message)
} else {
toast.error(
'An unknown error occurred in reading and parsing csv files'
)
}
}
}
readGamesCSVs()
}, [])
return games
}

37
src/hooks/useMuteLists.ts Normal file
View File

@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { MuteLists } from 'types'
import { useAppSelector } from './redux'
import { MetadataController } from 'controllers'
export const useMuteLists = () => {
const [muteLists, setMuteLists] = useState<{
admin: MuteLists
user: MuteLists
}>({
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
})
const userState = useAppSelector((state) => state.user)
useEffect(() => {
const getMuteLists = async () => {
const pubkey = userState.user?.pubkey as string | undefined
const metadataController = await MetadataController.getInstance()
metadataController.getMuteLists(pubkey).then((lists) => {
setMuteLists(lists)
})
}
getMuteLists()
}, [userState])
return muteLists
}

174
src/hooks/useReactions.ts Normal file
View File

@ -0,0 +1,174 @@
import { useState, useMemo } from 'react'
import { toast } from 'react-toastify'
import { REACTIONS } from 'constants.ts'
import { RelayController, UserRelaysType } from 'controllers'
import { useAppSelector, useDidMount } from 'hooks'
import { Event, Filter, UnsignedEvent, kinds } from 'nostr-tools'
import { abbreviateNumber, log, LogType, now } from 'utils'
type UseReactionsParams = {
pubkey: string
eTag: string
aTag?: string
}
export const useReactions = (params: UseReactionsParams) => {
const [isReactionInProgress, setIsReactionInProgress] = useState(false)
const [isDataLoaded, setIsDataLoaded] = useState(false)
const [reactionEvents, setReactionEvents] = useState<Event[]>([])
const userState = useAppSelector((state) => state.user)
useDidMount(() => {
const filter: Filter = {
kinds: [kinds.Reaction]
}
if (params.aTag) {
filter['#a'] = [params.aTag]
} else {
filter['#e'] = [params.eTag]
}
RelayController.getInstance()
.fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
.then((events) => {
setReactionEvents(events)
})
.finally(() => {
setIsDataLoaded(true)
})
})
const hasReactedPositively = useMemo(() => {
return (
!!userState.auth &&
reactionEvents.some(
(event) =>
event.pubkey === userState.user?.pubkey &&
(REACTIONS.positive.emojis.includes(event.content) ||
REACTIONS.positive.shortCodes.includes(event.content))
)
)
}, [reactionEvents, userState])
const hasReactedNegatively = useMemo(() => {
return (
!!userState.auth &&
reactionEvents.some(
(event) =>
event.pubkey === userState.user?.pubkey &&
(REACTIONS.negative.emojis.includes(event.content) ||
REACTIONS.negative.shortCodes.includes(event.content))
)
)
}, [reactionEvents, userState])
const getPubkey = async () => {
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
return null
}
return hexPubkey
}
const handleReaction = async (isPositive?: boolean) => {
if (!isDataLoaded || hasReactedPositively || hasReactedNegatively) return
if (isReactionInProgress) return
setIsReactionInProgress(true)
try {
const pubkey = await getPubkey()
if (!pubkey) return
const unsignedEvent: UnsignedEvent = {
kind: kinds.Reaction,
created_at: now(),
content: isPositive ? '+' : '-',
pubkey,
tags: [
['e', params.eTag],
['p', params.pubkey]
]
}
if (params.aTag) {
unsignedEvent.tags.push(['a', params.aTag])
}
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the reaction event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) return
setReactionEvents((prev) => [...prev, signedEvent])
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event,
params.pubkey,
UserRelaysType.Read
)
if (publishedOnRelays.length === 0) {
log(
true,
LogType.Error,
'Failed to publish reaction event on any relay'
)
return
}
} finally {
setIsReactionInProgress(false)
}
}
const { likesCount, disLikesCount } = useMemo(() => {
let positiveCount = 0
let negativeCount = 0
reactionEvents.forEach((event) => {
if (
REACTIONS.positive.emojis.includes(event.content) ||
REACTIONS.positive.shortCodes.includes(event.content)
) {
positiveCount++
} else if (
REACTIONS.negative.emojis.includes(event.content) ||
REACTIONS.negative.shortCodes.includes(event.content)
) {
negativeCount++
}
})
return {
likesCount: abbreviateNumber(positiveCount),
disLikesCount: abbreviateNumber(negativeCount)
}
}, [reactionEvents])
return {
isDataLoaded,
likesCount,
disLikesCount,
hasReactedPositively,
hasReactedNegatively,
handleReaction
}
}

View File

@ -2,21 +2,18 @@ import {
init as initNostrLogin,
launch as launchNostrLoginDialog
} from 'nostr-login'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import { Banner } from '../components/Banner'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap'
import { MetadataController, ZapController } from '../controllers'
import { useAppDispatch, useAppSelector } from '../hooks'
import { ZapPopUp } from '../components/Zap'
import { MetadataController } from '../controllers'
import { useAppDispatch, useAppSelector, useDidMount } from '../hooks'
import { appRoutes } from '../routes'
import { setAuth, setUser } from '../store/reducers/user'
import mainStyles from '../styles//main.module.scss'
import navStyles from '../styles/nav.module.scss'
import '../styles/popup.css'
import { PaymentRequest } from '../types'
import { formatNumber, npubToHex, unformatNumber } from '../utils'
import { npubToHex } from '../utils'
export const Header = () => {
const dispatch = useAppDispatch()
@ -173,7 +170,9 @@ export const Header = () => {
<div className={navStyles.NavMainBottom}>
<div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainBottomInside}>
<div className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}></div>
<div
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}
></div>
<div className={navStyles.NavMainBottomInsideLinks}>
<Link
to={appRoutes.games}
@ -200,31 +199,48 @@ export const Header = () => {
Blog
</Link>
</div>
<div className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}>
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x" target="_blank">
<img src="https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif" width="15px" />
<div
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}
>
<a
className={navStyles.NavMainBottomInsideOtherLink}
href='https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x'
target='_blank'
>
<img
src='https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif'
width='15px'
/>
</a>
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://x.com/DEGMods" target="_blank">
<a
className={navStyles.NavMainBottomInsideOtherLink}
href='https://x.com/DEGMods'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
</svg>
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
</svg>
</a>
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://www.youtube.com/@DEGModsDotCom" target="_blank">
<a
className={navStyles.NavMainBottomInsideOtherLink}
href='https://www.youtube.com/@DEGModsDotCom'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
</svg>
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
</svg>
</a>
</div>
</div>
@ -235,124 +251,13 @@ export const Header = () => {
}
const TipButtonWithDialog = React.memo(() => {
const [adminNpub, setAdminNpub] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
const [message, setMessage] = useState('')
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleClose = useCallback(() => {
setPaymentRequest(undefined)
setIsLoading(false)
setIsOpen(false)
}, [])
const handleQRExpiry = useCallback(() => {
setPaymentRequest(undefined)
}, [])
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const unformattedValue = unformatNumber(event.target.value)
setAmount(unformattedValue)
}
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return null
}
setLoadingSpinnerDesc('Getting admin metadata')
const metadataController = await MetadataController.getInstance()
const adminMetadata = await metadataController.findAdminMetadata()
if (!adminMetadata?.lud16) {
setIsLoading(false)
toast.error('Lighting address (lud16) is missing in admin metadata!')
return null
}
if (!adminMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in admin metadata!')
return null
}
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
return await zapController
.getLightningPaymentRequest(
adminMetadata.lud16,
amount,
adminMetadata.pubkey as string,
userHexKey,
message
)
.catch((err) => {
toast.error(err.message || err)
return null
})
.finally(() => {
setIsLoading(false)
})
}, [amount, message, userState])
const handleSend = useCallback(async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setIsLoading(true)
setLoadingSpinnerDesc('Sending payment!')
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
await zapController
.sendPayment(pr.pr)
.then(() => {
toast.success(`Successfully sent ${amount} sats!`)
handleClose()
})
.catch((err) => {
toast.error(err.message || err)
})
} else {
toast.warn('Webln is not present. Use QR code to send zap.')
setPaymentRequest(pr)
}
setIsLoading(false)
}, [amount, handleClose, generatePaymentRequest])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setPaymentRequest(pr)
}
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
setAdminNpub(metadataController.adminNpubs[0])
})
return (
<>
@ -371,91 +276,38 @@ const TipButtonWithDialog = React.memo(() => {
</svg>
Tip
</a>
{isOpen && (
<div id='PopUpMainZap' className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Tip/Zap DEG Mods</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='pUMCB_ZapsInsideAmount'>
<div className='inputLabelWrapperMain'>
<p
className='labelDescriptionMain'
style={{ textAlign: 'center' }}
>
If you don't want the development and maintenance of DEG
Mods to stop, then a tip helps!
</p>
<label className='form-label labelMain'>
Amount (Satoshis)
</label>
<input
className='inputMain'
type='text'
inputMode='numeric'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Message (optional)
</label>
<input
type='text'
className='inputMain'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
<div className='BTCAddressPopZap'>
<p>
DEG Mod's Silent Payment Bitcoin Address (Be careful. <a href='https://youtu.be/payDPlHzp58?t=215' className='linkMain' target='_blank'>Learn more</a>):<br />
<span className='BTCAddressPopZapTextSpan'>sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd</span>
</p>
</div>
</div>
</div>
</div>
{isOpen && adminNpub && (
<ZapPopUp
title='Tip/Zap DEG Mods'
receiver={adminNpub}
handleClose={() => setIsOpen(false)}
labelDescriptionMain={
<p className='labelDescriptionMain' style={{ textAlign: 'center' }}>
If you don't want the development and maintenance of DEG Mods to
stop, then a tip helps!
</p>
}
lastNode={
<div className='BTCAddressPopZap'>
<p>
DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '}
<a
href='https://youtu.be/payDPlHzp58?t=215'
className='linkMain'
target='_blank'
>
Learn more
</a>
):
<br />
<span className='BTCAddressPopZapTextSpan'>
sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd
</span>
</p>
</div>
</div>
</div>
}
/>
)}
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
})

View File

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

115
src/layout/socialNav.tsx Normal file
View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import 'styles/socialNav.css'
export const SocialNav = () => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
const toggleNav = () => {
setIsCollapsed(!isCollapsed)
}
return (
<div
className='socialNav'
style={{
transform: isCollapsed ? 'translateX(0)' : 'translateX(50%)',
right: isCollapsed ? '0%' : '50%'
}}
>
<div className='socialNavInsideWrapper'>
{!isCollapsed && (
<div className='socialNavInside'>
<Link
to={appRoutes.home}
className='btn btnMain socialNavInsideBtn socialNavInsideBtnActive'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -32 576 576'
width='1em'
height='1em'
fill='currentColor'
>
<path d='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'></path>
</svg>
</Link>
<Link
className='btn btnMain socialNavInsideBtn'
to={appRoutes.home}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='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'></path>
</svg>
</Link>
<Link
className='btn btnMain socialNavInsideBtn'
to={appRoutes.home}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='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'></path>
</svg>
</Link>
<Link
className='btn btnMain socialNavInsideBtn'
to={appRoutes.search}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</Link>
<Link
className='btn btnMain socialNavInsideBtn'
to={getProfilePageRoute('xyz')}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'></path>
</svg>
</Link>
</div>
)}
<div className='socialNavCollapse' onClick={toggleNav}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-128 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='socialNavCollapseIcon'
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(180deg)'
}}
>
<path d='M192 448c-8.188 0-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l137.4 137.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448z'></path>
</svg>
</div>
</div>
</div>
)
}

299
src/pages/game.tsx Normal file
View File

@ -0,0 +1,299 @@
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
import { RelayController } from 'controllers'
import { useAppSelector, useMuteLists } from 'hooks'
import { Filter, kinds, nip19 } from 'nostr-tools'
import { Subscription } from 'nostr-tools/abstract-relay'
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getModPageRoute } from 'routes'
import { ModDetails } from 'types'
import { extractModData, isModDataComplete, log, LogType } from 'utils'
enum SortByEnum {
Latest = 'Latest',
Oldest = 'Oldest',
Best_Rated = 'Best Rated',
Worst_Rated = 'Worst Rated'
}
enum ModeratedFilterEnum {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated',
Unmoderated_Fully = 'Unmoderated Fully'
}
interface FilterOptions {
sort: SortByEnum
moderated: ModeratedFilterEnum
}
export const GamePage = () => {
const params = useParams()
const { name: gameName } = params
const muteLists = useMuteLists()
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
moderated: ModeratedFilterEnum.Moderated
})
const [mods, setMods] = useState<ModDetails[]>([])
const hasEffectRun = useRef(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const userState = useAppSelector((state) => state.user)
const filteredMods = useMemo(() => {
let filtered: ModDetails[] = [...mods]
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilterEnum.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
!muteLists.admin.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.moderated === ModeratedFilterEnum.Moderated) {
filtered = filtered.filter(
(mod) =>
!muteLists.user.authors.includes(mod.author) &&
!muteLists.user.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.sort === SortByEnum.Latest) {
filtered.sort((a, b) => b.published_at - a.published_at)
} else if (filterOptions.sort === SortByEnum.Oldest) {
filtered.sort((a, b) => a.published_at - b.published_at)
}
return filtered
}, [
mods,
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
muteLists
])
// Pagination logic
const totalGames = filteredMods.length
const totalPages = Math.ceil(totalGames / MAX_MODS_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_MODS_PER_PAGE
const endIndex = startIndex + MAX_MODS_PER_PAGE
const currentMods = filteredMods.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
}
}
useEffect(() => {
if (hasEffectRun.current) {
return
}
hasEffectRun.current = true // Set it so the effect doesn't run again
const filter: Filter = {
kinds: [kinds.ClassifiedListing],
'#t': [T_TAG_VALUE]
}
setIsSubscribing(true)
let subscriptions: Subscription[] = []
RelayController.getInstance()
.subscribeForEvents(filter, [], (event) => {
if (isModDataComplete(event)) {
const mod = extractModData(event)
if (mod.game === gameName) setMods((prev) => [...prev, mod])
}
})
.then((subs) => {
subscriptions = subs
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in subscribing to relays.',
err
)
toast.error(err.message || err)
})
.finally(() => {
setIsSubscribing(false)
})
// Cleanup function to stop all subscriptions
return () => {
subscriptions.forEach((sub) => sub.close()) // close each subscription
}
}, [gameName])
if (!gameName) return null
return (
<>
{isSubscribing && (
<LoadingSpinner desc='Subscribing to relays for mods' />
)}
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Game:&nbsp;
<span className='IBMSMTitleMainHeadingSpan'>
{gameName}
</span>
</h2>
</div>
</div>
</div>
<Filters
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods.map((mod) => {
const route = getModPageRoute(
nip19.naddrEncode({
identifier: mod.aTag,
pubkey: mod.author,
kind: kinds.ClassifiedListing
})
)
return (
<ModCard
key={mod.id}
title={mod.title}
gameName={mod.game}
summary={mod.summary}
imageUrl={mod.featuredImageUrl}
route={route}
/>
)
})}
</div>
</div>
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
</div>
</div>
</div>
</>
)
}
type FiltersProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
const Filters = React.memo(
({ filterOptions, setFilterOptions }: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortByEnum).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilterEnum).map((item, index) => {
if (item === ModeratedFilterEnum.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
)

View File

@ -1,9 +1,51 @@
import '../styles/pagination.css'
import '../styles/styles.css'
import '../styles/search.css'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { MAX_GAMES_PER_PAGE } from 'constants.ts'
import { useGames } from 'hooks'
import { useRef, useState } from 'react'
import { GameCard } from '../components/GameCard'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import { createSearchParams, useNavigate } from 'react-router-dom'
import { appRoutes } from 'routes'
export const GamesPage = () => {
const navigate = useNavigate()
const searchTermRef = useRef<HTMLInputElement>(null)
const games = useGames()
const [currentPage, setCurrentPage] = useState(1)
// Pagination logic
const totalGames = games.length
const totalPages = Math.ceil(totalGames / MAX_GAMES_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_GAMES_PER_PAGE
const endIndex = startIndex + MAX_GAMES_PER_PAGE
const currentGames = games.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
}
}
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
if (value !== '') {
const searchParams = createSearchParams({
searchTerm: value,
searching: 'Games'
})
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
}
}
// Handle "Enter" key press inside the input
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -11,13 +53,23 @@ export const GamesPage = () => {
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Games (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>Games</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input type='text' className='SMIWInput' />
<button className='btn btnMain SMIWButton' type='button'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
@ -35,46 +87,20 @@ export const GamesPage = () => {
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
</div>
</div>
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-left'></i>
</a>
<div className='PaginationMainInsideBoxGroup'>
<a className='PaginationMainInsideBox PMIBActive' href='#'>
<p>1</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>2</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>3</p>
</a>
<p className='PaginationMainInsideBox PMIBDots'>...</p>
<a className='PaginationMainInsideBox' href='#'>
<p>8</p>
</a>
</div>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-right'></i>
</a>
</div>
{currentGames.map((game) => (
<GameCard
key={game['Game Name']}
title={game['Game Name']}
imageUrl={game['Boxart image']}
/>
))}
</div>
</div>
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
</div>
</div>
</div>

View File

@ -1,123 +1,61 @@
import { Filter, kinds, nip19 } from 'nostr-tools'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { A11y, Navigation, Pagination, Autoplay } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
import { BlogCard } from '../components/BlogCard'
import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA } from '../constants'
import { RelayController } from '../controllers'
import { useDidMount } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { ModDetails } from '../types'
import {
extractModData,
fetchMods,
handleModImageError,
log,
LogType
} from '../utils'
import '../styles/cardLists.css'
import '../styles/SimpleSlider.css'
import '../styles/styles.css'
// Import Swiper styles
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
export const HomePage = () => {
const navigate = useNavigate()
return (
<div className='InnerBodyMain'>
<div className='SliderWrapper'>
<div className='ContainerMain'>
<div className='IBMSecMain'>
<div className='simple-slider IBMSMSlider'>
<div className='swiper-container IBMSMSliderContainer'>
<div className='swiper-wrapper IBMSMSliderContainerWrapper'>
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
<div
className='IBMSMSCWSPic'
style={{
background:
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
}}
></div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
<p className='IBMSMSCWSInfoText'>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Integer nec odio. Praesent libero. Sed cursus ante
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
nec tellus sed augue semper porta. Mauris massa.
Vestibulum lacinia arcu eget nulla. className aptent
taciti sociosqu ad litora torquent per conubia nostra,
per inceptos himenaeos. Curabitur sodales ligula in
libero.
<br />
</p>
<div className='IBMSMSliderContainerWrapperSliderAction'>
<a
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
role='button'
href='mods-inner.html'
>
Check it out
</a>
</div>
</div>
</div>
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
<div
className='IBMSMSCWSPic'
style={{
background:
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
}}
></div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
<p className='IBMSMSCWSInfoText'>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Integer nec odio. Praesent libero. Sed cursus ante
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
nec tellus sed augue semper porta. Mauris massa.
Vestibulum lacinia arcu eget nulla. className aptent
taciti sociosqu ad litora torquent per conubia nostra,
per inceptos himenaeos. Curabitur sodales ligula in
libero.
<br />
</p>
<div className='IBMSMSliderContainerWrapperSliderAction'>
<a
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
role='button'
href='mods-inner.html'
>
Check it out
</a>
</div>
</div>
</div>
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
<div
className='IBMSMSCWSPic'
style={{
background:
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
}}
></div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
<p className='IBMSMSCWSInfoText'>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Integer nec odio. Praesent libero. Sed cursus ante
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
nec tellus sed augue semper porta. Mauris massa.
Vestibulum lacinia arcu eget nulla. className aptent
taciti sociosqu ad litora torquent per conubia nostra,
per inceptos himenaeos. Curabitur sodales ligula in
libero.
<br />
</p>
<div className='IBMSMSliderContainerWrapperSliderAction'>
<a
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
role='button'
href='mods-inner.html'
>
Check it out
</a>
</div>
</div>
</div>
</div>
<div className='swiper-pagination'></div>
<div className='swiper-button-prev'></div>
<div className='swiper-button-next'></div>
</div>
<Swiper
className='swiper-container IBMSMSliderContainer'
wrapperClass='swiper-wrapper IBMSMSliderContainerWrapper'
modules={[Navigation, Pagination, A11y, Autoplay]}
pagination={{ clickable: true, dynamicBullets: true }}
slidesPerView={1}
autoplay={{ delay: 5000 }}
speed={1000}
navigation
loop
>
{LANDING_PAGE_DATA.featuredSlider.map((naddr) => (
<SwiperSlide
key={naddr}
className='swiper-slide IBMSMSliderContainerWrapperSlider'
>
<SlideContent naddr={naddr} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
@ -126,20 +64,22 @@ export const HomePage = () => {
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Cool Games (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>Cool Games</h2>
</div>
<div className='IBMSMList IBMSMListFeaturedAlt'>
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
{LANDING_PAGE_DATA.featuredGames.map((game) => (
<GameCard
key={game.title}
title={game.title}
imageUrl={game.imageUrl}
/>
))}
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
onClick={() => navigate(appRoutes.games)}
>
View All
</a>
@ -147,107 +87,24 @@ export const HomePage = () => {
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Awesome Mods (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>Awesome Mods</h2>
</div>
<div className='IBMSMList IBMSMListAlt'>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
{LANDING_PAGE_DATA.awesomeMods.map((naddr) => (
<DisplayMod key={naddr} naddr={naddr} />
))}
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
>
View All
</a>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Latest Mods (WIP)</h2>
</div>
<div className='IBMSMList'>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
<ModCard
title='Placeholder'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
handleClick={() => {
alert(
'these are dummy mods. So navigation on these are not implemented yet'
)
}}
/>
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
onClick={() => navigate(appRoutes.mods)}
>
View All
</a>
</div>
</div>
<DisplayLatestMods />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
@ -274,3 +131,197 @@ export const HomePage = () => {
</div>
)
}
type SlideContentProps = {
naddr: string
}
const SlideContent = ({ naddr }: SlideContentProps) => {
const navigate = useNavigate()
const [mod, setMod] = useState<ModDetails>()
useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: Filter = {
'#a': [identifier],
authors: [pubkey],
kinds: [kind]
}
RelayController.getInstance()
.fetchEvent(filter, relays)
.then((event) => {
if (event) {
const extracted = extractModData(event)
setMod(extracted)
}
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in fetching mod details from relays',
err
)
})
})
if (!mod) return <Spinner />
return (
<>
<div className='IBMSMSCWSPicWrapper'>
<img
src={mod.featuredImageUrl}
onError={handleModImageError}
className='IBMSMSCWSPic'
/>
</div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>{mod.title}</h3>
<div className='IBMSMSCWSInfoTextWrapper'>
<p className='IBMSMSCWSInfoText'>
{mod.summary}
<br />
</p>
</div>
<p className='IBMSMSCWSInfoText IBMSMSCWSInfoText2'>
{mod.game}
<br />
</p>
<div className='IBMSMSliderContainerWrapperSliderAction'>
<a
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
role='button'
onClick={() => navigate(getModPageRoute(naddr))}
>
Check it out
</a>
</div>
</div>
</>
)
}
type DisplayModProps = {
naddr: string
}
const DisplayMod = ({ naddr }: DisplayModProps) => {
const [mod, setMod] = useState<ModDetails>()
useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: Filter = {
'#a': [identifier],
authors: [pubkey],
kinds: [kind]
}
RelayController.getInstance()
.fetchEvent(filter, relays)
.then((event) => {
if (event) {
const extracted = extractModData(event)
setMod(extracted)
}
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in fetching mod details from relays',
err
)
})
})
if (!mod) return <Spinner />
const route = getModPageRoute(naddr)
return (
<ModCard
title={mod.title}
gameName={mod.game}
summary={mod.summary}
imageUrl={mod.featuredImageUrl}
route={route}
/>
)
}
const DisplayLatestMods = () => {
const navigate = useNavigate()
const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true)
const [latestMods, setLatestMods] = useState<ModDetails[]>([])
useDidMount(() => {
fetchMods({ source: window.location.host })
.then((res) => {
const mods = res
.sort((a, b) => b.published_at - a.published_at)
.slice(0, 4)
setLatestMods(mods)
})
.finally(() => {
setIsFetchingLatestMods(false)
})
})
return (
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Latest Mods</h2>
</div>
<div className='IBMSMList'>
{isFetchingLatestMods ? (
<Spinner />
) : (
latestMods.map((mod) => {
const route = getModPageRoute(
nip19.naddrEncode({
identifier: mod.aTag,
pubkey: mod.author,
kind: kinds.ClassifiedListing
})
)
return (
<ModCard
key={mod.id}
title={mod.title}
gameName={mod.game}
summary={mod.summary}
imageUrl={mod.featuredImageUrl}
route={route}
/>
)
})
)}
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
onClick={() => navigate(appRoutes.mods)}
>
View All
</a>
</div>
</div>
)
}
const Spinner = () => {
return (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
}

File diff suppressed because it is too large Load Diff

1389
src/pages/mod/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,592 @@
import { ZapPopUp } from 'components/Zap'
import {
MetadataController,
RelayController,
UserRelaysType
} from 'controllers'
import { formatDate } from 'date-fns'
import { useAppSelector, useDidMount, useReactions } from 'hooks'
import {
Event,
kinds,
nip19,
Filter as NostrEventFilter,
UnsignedEvent
} from 'nostr-tools'
import React, { useEffect, useMemo } from 'react'
import { Dispatch, SetStateAction, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getProfilePageRoute } from 'routes'
import { ModDetails, UserProfile } from 'types'
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
}
enum CommentEventStatus {
Publishing = 'Publishing comment...',
Published = 'Published!',
Failed = 'Failed to publish comment.'
}
interface CommentEvent extends Event {
status?: CommentEventStatus
}
type Props = {
modDetails: ModDetails
setCommentCount: Dispatch<SetStateAction<number>>
}
export const Comments = ({ modDetails, setCommentCount }: Props) => {
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
})
useEffect(() => {
setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount])
const userState = useAppSelector((state) => state.user)
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
const authorReadRelays = await metadataController.findUserRelays(
modDetails.author,
UserRelaysType.Read
)
const filter: NostrEventFilter = {
kinds: [kinds.ShortTextNote],
'#a': [modDetails.aTag]
}
RelayController.getInstance().subscribeForEvents(
filter,
authorReadRelays,
(event) => {
setCommentEvents((prev) => {
if (prev.find((e) => e.id === event.id)) {
return [...prev]
}
return [event, ...prev]
})
}
)
})
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', modDetails.id],
['a', modDetails.aTag]
]
}
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 publish = async () => {
const metadataController = await MetadataController.getInstance()
const modAuthorReadRelays = await metadataController.findUserRelays(
modDetails.author,
UserRelaysType.Read
)
const commentatorWriteRelays = await metadataController.findUserRelays(
pubkey,
UserRelaysType.Write
)
const combinedRelays = [
...new Set(...modAuthorReadRelays, ...commentatorWriteRelays)
]
const publishedOnRelays =
await RelayController.getInstance().publishOnRelays(
signedEvent,
combinedRelays
)
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)
}
publish()
return true
}
const comments = useMemo(() => {
let filteredComments = commentEvents
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.pubkey === modDetails.author
)
}
if (filterOptions.sort === SortByEnum.Latest) {
filteredComments.sort((a, b) => b.created_at - a.created_at)
} else if (filterOptions.sort === SortByEnum.Oldest) {
filteredComments.sort((a, b) => a.created_at - b.created_at)
}
return filteredComments
}, [commentEvents, filterOptions, modDetails.author])
return (
<div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'>
<CommentForm handleSubmit={handleSubmit} />
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSMSMBSSCommentsList'>
{comments.map((event) => (
<Comment key={event.id} {...event} />
))}
</div>
</div>
</div>
)
}
type CommentFormProps = {
handleSubmit: (content: string) => Promise<boolean>
}
const CommentForm = ({ handleSubmit }: CommentFormProps) => {
const [isSubmitting, setIsSubmitting] = useState(false)
const [commentText, setCommentText] = useState('')
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(commentText)
if (submitted) setCommentText('')
setIsSubmitting(false)
}
return (
<div className='IBMSMSMBSSCommentsCreation'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
className='IBMSMSMBSSCC_Top_Box'
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
/>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
<button
className='btnMain'
onClick={handleComment}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Comment'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
)
}
type FilterProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
const Filter = React.memo(
({ filterOptions, setFilterOptions }: FilterProps) => {
return (
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortByEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.author}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(AuthorFilterEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
author: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
)
}
)
const Comment = (props: CommentEvent) => {
const [profile, setProfile] = useState<UserProfile>()
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.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'>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
{profile?.displayName || profile?.name || ''}{' '}
</a>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
{hexToNpub(props.pubkey)}
</a>
</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
})
if (!isDataLoaded) return null
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`}
onClick={() => handleReaction(true)}
>
<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'>{likesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`}
onClick={() => handleReaction()}
>
<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'>{disLikesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</>
)
}
const Zap = (props: Event) => {
const [isOpen, setIsOpen] = useState(false)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
useDidMount(() => {
RelayController.getInstance()
.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

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

View File

@ -0,0 +1,256 @@
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ZapButtons, ZapPopUp, ZapPresets, ZapQR } from 'components/Zap'
import { MetadataController, RelayController, ZapController } from 'controllers'
import { useAppSelector, useDidMount } from 'hooks'
import { useCallback, useState } from 'react'
import { toast } from 'react-toastify'
import { ModDetails, PaymentRequest } from 'types'
import { abbreviateNumber, formatNumber, unformatNumber } from 'utils'
type ZapProps = {
modDetails: ModDetails
}
export const Zap = ({ modDetails }: ZapProps) => {
const [isOpen, setIsOpen] = useState(false)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
useDidMount(() => {
RelayController.getInstance()
.getTotalZapAmount(
modDetails.author,
modDetails.id,
modDetails.aTag,
userState.user?.pubkey as string
)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
setHasZapped(res.hasZapped)
})
.catch((err) => {
toast.error(err.message || err)
})
})
return (
<>
<div
id='reactBolt'
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
}`}
onClick={() => setIsOpen(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(totalZappedAmount)}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{isOpen && (
<ZapPopUp
title='Tip/Zap'
receiver={modDetails.author}
eventId={modDetails.id}
aTag={modDetails.aTag}
handleClose={() => setIsOpen(false)}
lastNode={<ZapSite />}
notCloseAfterZap
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
/>
)}
</>
)
}
const ZapSite = () => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState(0)
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const unformattedValue = unformatNumber(event.target.value)
setAmount(unformattedValue)
}
const handleClose = useCallback(() => {
setPaymentRequest(undefined)
setIsLoading(false)
}, [])
const handleQRExpiry = useCallback(() => {
setPaymentRequest(undefined)
}, [])
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return null
}
setLoadingSpinnerDesc('Getting admin metadata')
const metadataController = await MetadataController.getInstance()
const adminMetadata = await metadataController.findAdminMetadata()
if (!adminMetadata?.lud16) {
setIsLoading(false)
toast.error('Lighting address (lud16) is missing in admin metadata!')
return null
}
if (!adminMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in admin metadata!')
return null
}
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
return await zapController
.getLightningPaymentRequest(
adminMetadata.lud16,
amount,
adminMetadata.pubkey as string,
userHexKey
)
.catch((err) => {
toast.error(err.message || err)
return null
})
.finally(() => {
setIsLoading(false)
})
}, [amount, userState])
const handleSend = useCallback(async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setIsLoading(true)
setLoadingSpinnerDesc('Sending payment!')
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
await zapController
.sendPayment(pr.pr)
.then(() => {
toast.success(`Successfully sent ${amount} sats!`)
handleClose()
})
.catch((err) => {
toast.error(err.message || err)
})
} else {
toast.warn('Webln is not present. Use QR code to send zap.')
setPaymentRequest(pr)
}
setIsLoading(false)
}, [amount, handleClose, generatePaymentRequest])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setPaymentRequest(pr)
}
return (
<>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Tip DEG Mods too (Optional)
</label>
<div className='ZapSplitUserBox'>
<div className='ZapSplitUserBoxUser'>
<div
className='ZapSplitUserBoxUserPic'
style={{
background: `url('/assets/img/Logo%20with%20circle.png')
center / cover no-repeat`
}}
></div>
<div className='ZapSplitUserBoxUserDetails'>
<p className='ZapSplitUserBoxUserDetailsName'>DEG Mods</p>
<p className='ZapSplitUserBoxUserDetailsHandle'>
degmods@degmods.com
</p>
</div>
</div>
<p className='ZapSplitUserBoxText'>
Help with the development, maintenance, management, and growth of
DEG Mods.
</p>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Amount (Satoshis)</label>
<input
type='text'
className='inputMain'
inputMode='numeric'
placeholder='69 or 420? or 69,420?'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
</div>
</div>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}

View File

@ -1,3 +1,4 @@
import { Pagination } from 'components/Pagination'
import { kinds, nip19 } from 'nostr-tools'
import React, {
Dispatch,
@ -7,19 +8,18 @@ import React, {
useMemo,
useState
} from 'react'
import { useNavigate } from 'react-router-dom'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { ModCard } from '../components/ModCard'
import { MOD_FILTER_LIMIT } from '../constants'
import { MetadataController } from '../controllers'
import { useDidMount } from '../hooks'
import { getModsInnerPageRoute } from '../routes'
import { useAppSelector, useDidMount, useMuteLists } from '../hooks'
import { getModPageRoute } from '../routes'
import '../styles/filters.css'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import { ModDetails, MuteLists } from '../types'
import { ModDetails } from '../types'
import { fetchMods } from '../utils'
import { MOD_FILTER_LIMIT } from '../constants'
enum SortBy {
Latest = 'Latest',
@ -36,7 +36,8 @@ enum NSFWFilter {
enum ModeratedFilter {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated'
Unmoderated = 'Unmoderated',
Unmoderated_Fully = 'Unmoderated Fully'
}
interface FilterOptions {
@ -47,7 +48,6 @@ interface FilterOptions {
}
export const ModsPage = () => {
const navigate = useNavigate()
const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
@ -56,23 +56,25 @@ export const ModsPage = () => {
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [muteLists, setMuteLists] = useState<MuteLists>({
authors: [],
eventIds: []
})
const muteLists = useMuteLists()
const [nsfwList, setNSFWList] = useState<string[]>([])
const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user)
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.getAdminsMuteLists().then((lists) => {
setMuteLists(lists)
metadataController.getNSFWList().then((list) => {
setNSFWList(list)
})
})
useEffect(() => {
setIsFetching(true)
fetchMods(filterOptions.source)
fetchMods({ source: filterOptions.source })
.then((res) => {
setMods(res)
})
@ -85,9 +87,12 @@ export const ModsPage = () => {
setIsFetching(true)
const until =
mods.length > 0 ? mods[mods.length - 1].edited_at - 1 : undefined
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
fetchMods(filterOptions.source, until)
fetchMods({
source: filterOptions.source,
until
})
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
@ -100,9 +105,12 @@ export const ModsPage = () => {
const handlePrev = useCallback(() => {
setIsFetching(true)
const since = mods.length > 0 ? mods[0].edited_at + 1 : undefined
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
fetchMods(filterOptions.source, undefined, since)
fetchMods({
source: filterOptions.source,
since
})
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
@ -118,39 +126,54 @@ export const ModsPage = () => {
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
// If 'Hide_NSFW' is selected, filter out NSFW mods
return mods.filter((mod) => !mod.nsfw)
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
case NSFWFilter.Show_NSFW:
// If 'Show_NSFW' is selected, return all mods (no filtering)
return mods
case NSFWFilter.Only_NSFW:
// If 'Only_NSFW' is selected, filter to show only NSFW mods
return mods.filter((mod) => mod.nsfw)
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
}
}
let filtered = nsfwFilter(mods)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
!muteLists.admin.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
filtered = filtered.filter(
(mod) =>
!muteLists.authors.includes(mod.author) &&
!muteLists.eventIds.includes(mod.id)
!muteLists.user.authors.includes(mod.author) &&
!muteLists.user.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.sort === SortBy.Latest) {
filtered.sort((a, b) => b.edited_at - a.edited_at)
filtered.sort((a, b) => b.published_at - a.published_at)
} else if (filterOptions.sort === SortBy.Oldest) {
filtered.sort((a, b) => a.edited_at - b.edited_at)
filtered.sort((a, b) => a.published_at - b.published_at)
}
return filtered
}, [
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
filterOptions.nsfw,
mods,
muteLists
muteLists,
nsfwList
])
return (
@ -167,25 +190,26 @@ export const ModsPage = () => {
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredModList.map((mod) => (
<ModCard
key={mod.id}
title={mod.title}
summary={mod.summary}
backgroundLink={mod.featuredImageUrl}
handleClick={() =>
navigate(
getModsInnerPageRoute(
nip19.naddrEncode({
identifier: mod.aTag,
pubkey: mod.author,
kind: kinds.ClassifiedListing
})
)
)
}
/>
))}
{filteredModList.map((mod) => {
const route = getModPageRoute(
nip19.naddrEncode({
identifier: mod.aTag,
pubkey: mod.author,
kind: kinds.ClassifiedListing
})
)
return (
<ModCard
key={mod.id}
title={mod.title}
gameName={mod.game}
summary={mod.summary}
imageUrl={mod.featuredImageUrl}
route={route}
/>
)
})}
</div>
</div>
@ -239,6 +263,8 @@ type FiltersProps = {
const Filters = React.memo(
({ filterOptions, setFilterOptions }: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
@ -282,20 +308,30 @@ const Filters = React.memo(
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
))}
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
@ -370,42 +406,3 @@ const Filters = React.memo(
)
}
)
type PaginationProps = {
page: number
disabledNext: boolean
handlePrev: () => void
handleNext: () => void
}
const Pagination = React.memo(
({ page, disabledNext, handlePrev, handleNext }: PaginationProps) => {
return (
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<button
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={handlePrev}
disabled={page === 1}
>
<i className='fas fa-chevron-left'></i>
</button>
<div className='PaginationMainInsideBoxGroup'>
<button className='PaginationMainInsideBox PMIBActive'>
<p>{page}</p>
</button>
</div>
<button
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
onClick={handleNext}
disabled={disabledNext}
>
<i className='fas fa-chevron-right'></i>
</button>
</div>
</div>
</div>
)
}
)

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

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

586
src/pages/search.tsx Normal file
View File

@ -0,0 +1,586 @@
import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk'
import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection'
import {
MAX_GAMES_PER_PAGE,
MAX_MODS_PER_PAGE,
T_TAG_VALUE
} from 'constants.ts'
import { RelayController } from 'controllers'
import { useAppSelector, useGames, useMuteLists } from 'hooks'
import { Filter, kinds, nip19 } from 'nostr-tools'
import { Subscription } from 'nostr-tools/abstract-relay'
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getModPageRoute } from 'routes'
import { ModDetails, MuteLists } from 'types'
import { extractModData, isModDataComplete, log, LogType } from 'utils'
enum SortByEnum {
Latest = 'Latest',
Oldest = 'Oldest',
Best_Rated = 'Best Rated',
Worst_Rated = 'Worst Rated'
}
enum ModeratedFilterEnum {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated',
Unmoderated_Fully = 'Unmoderated Fully'
}
enum SearchingFilterEnum {
Mods = 'Mods',
Games = 'Games',
Users = 'Users'
}
interface FilterOptions {
sort: SortByEnum
moderated: ModeratedFilterEnum
searching: SearchingFilterEnum
}
export const SearchPage = () => {
const [searchParams] = useSearchParams()
const muteLists = useMuteLists()
const searchTermRef = useRef<HTMLInputElement>(null)
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
moderated: ModeratedFilterEnum.Moderated,
searching:
(searchParams.get('searching') as SearchingFilterEnum) ||
SearchingFilterEnum.Mods
})
const [searchTerm, setSearchTerm] = useState(
searchParams.get('searchTerm') || ''
)
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
}
// Handle "Enter" key press inside the input
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Search:&nbsp;
<span className='IBMSMTitleMainHeadingSpan'>
{searchTerm}
</span>
</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<Filters
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
{filterOptions.searching === SearchingFilterEnum.Mods && (
<ModsResult
searchTerm={searchTerm}
filterOptions={filterOptions}
muteLists={muteLists}
/>
)}
{filterOptions.searching === SearchingFilterEnum.Users && (
<UsersResult
searchTerm={searchTerm}
muteLists={muteLists}
moderationFilter={filterOptions.moderated}
/>
)}
{filterOptions.searching === SearchingFilterEnum.Games && (
<GamesResult searchTerm={searchTerm} />
)}
</div>
</div>
</div>
)
}
type FiltersProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
const Filters = React.memo(
({ filterOptions, setFilterOptions }: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortByEnum).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilterEnum).map((item, index) => {
if (item === ModeratedFilterEnum.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Searching: {filterOptions.searching}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SearchingFilterEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
searching: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
)
type ModsResultProps = {
filterOptions: FilterOptions
searchTerm: string
muteLists: {
admin: MuteLists
user: MuteLists
}
}
const ModsResult = ({
filterOptions,
searchTerm,
muteLists
}: ModsResultProps) => {
const hasEffectRun = useRef(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([])
const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (hasEffectRun.current) {
return
}
hasEffectRun.current = true // Set it so the effect doesn't run again
const filter: Filter = {
kinds: [kinds.ClassifiedListing],
'#t': [T_TAG_VALUE]
}
setIsSubscribing(true)
let subscriptions: Subscription[] = []
RelayController.getInstance()
.subscribeForEvents(filter, [], (event) => {
if (isModDataComplete(event)) {
const mod = extractModData(event)
setMods((prev) => [...prev, mod])
}
})
.then((subs) => {
subscriptions = subs
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in subscribing to relays.',
err
)
toast.error(err.message || err)
})
.finally(() => {
setIsSubscribing(false)
})
// Cleanup function to stop all subscriptions
return () => {
subscriptions.forEach((sub) => sub.close()) // close each subscription
}
}, [])
useEffect(() => {
setPage(1)
}, [searchTerm])
const filteredMods = useMemo(() => {
if (searchTerm === '') return []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
const filterFn = (mod: ModDetails) =>
mod.title.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.game.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.summary.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.body.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.tags.findIndex((tag) =>
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn)
}, [mods, searchTerm])
const filteredModList = useMemo(() => {
let filtered: ModDetails[] = [...filteredMods]
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilterEnum.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
!muteLists.admin.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.moderated === ModeratedFilterEnum.Moderated) {
filtered = filtered.filter(
(mod) =>
!muteLists.user.authors.includes(mod.author) &&
!muteLists.user.replaceableEvents.includes(mod.aTag)
)
}
if (filterOptions.sort === SortByEnum.Latest) {
filtered.sort((a, b) => b.published_at - a.published_at)
} else if (filterOptions.sort === SortByEnum.Oldest) {
filtered.sort((a, b) => a.published_at - b.published_at)
}
return filtered
}, [
filteredMods,
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
muteLists
])
const handleNext = () => {
setPage((prev) => prev + 1)
}
const handlePrev = () => {
setPage((prev) => prev - 1)
}
return (
<>
{isSubscribing && (
<LoadingSpinner desc='Subscribing to relays for mods' />
)}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredModList
.slice((page - 1) * MAX_MODS_PER_PAGE, page * MAX_MODS_PER_PAGE)
.map((mod) => {
const route = getModPageRoute(
nip19.naddrEncode({
identifier: mod.aTag,
pubkey: mod.author,
kind: kinds.ClassifiedListing
})
)
return (
<ModCard
key={mod.id}
title={mod.title}
gameName={mod.game}
summary={mod.summary}
imageUrl={mod.featuredImageUrl}
route={route}
/>
)
})}
</div>
</div>
<Pagination
page={page}
disabledNext={filteredModList.length <= page * MAX_MODS_PER_PAGE}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)
}
type UsersResultProps = {
searchTerm: string
moderationFilter: ModeratedFilterEnum
muteLists: {
admin: MuteLists
user: MuteLists
}
}
const UsersResult = ({
searchTerm,
moderationFilter,
muteLists
}: UsersResultProps) => {
const [isFetching, setIsFetching] = useState(false)
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (searchTerm === '') {
setProfiles([])
} else {
const filter: Filter = {
kinds: [kinds.Metadata],
search: searchTerm
}
setIsFetching(true)
RelayController.getInstance()
.fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es'])
.then((events) => {
const results = events.map((event) => {
const ndkEvent = new NDKEvent(undefined, event)
const profile = profileFromEvent(ndkEvent)
return profile
})
setProfiles(results)
})
.catch((err) => {
log(true, LogType.Error, 'An error occurred in fetching users', err)
})
.finally(() => {
setIsFetching(false)
})
}
}, [searchTerm])
const filteredProfiles = useMemo(() => {
let filtered = [...profiles]
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
moderationFilter === ModeratedFilterEnum.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(profile) => !muteLists.admin.authors.includes(profile.pubkey as string)
)
}
if (moderationFilter === ModeratedFilterEnum.Moderated) {
filtered = filtered.filter(
(profile) => !muteLists.user.authors.includes(profile.pubkey as string)
)
}
return filtered
}, [userState.user?.npub, moderationFilter, profiles, muteLists])
return (
<>
{isFetching && <LoadingSpinner desc='Fetching Profiles' />}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredProfiles.map((profile) => {
if (profile.pubkey) {
return (
<ErrorBoundary key={profile.pubkey}>
<Profile profile={profile} />
</ErrorBoundary>
)
}
return null
})}
</div>
</div>
</>
)
}
type GamesResultProps = {
searchTerm: string
}
const GamesResult = ({ searchTerm }: GamesResultProps) => {
const games = useGames()
const [page, setPage] = useState(1)
// Reset the page to 1 whenever searchTerm changes
useEffect(() => {
setPage(1)
}, [searchTerm])
const filteredGames = useMemo(() => {
if (searchTerm === '') return []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
return games.filter((game) =>
game['Game Name'].toLowerCase().includes(lowerCaseSearchTerm)
)
}, [searchTerm, games])
const handleNext = () => {
setPage((prev) => prev + 1)
}
const handlePrev = () => {
setPage((prev) => prev - 1)
}
return (
<>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
{filteredGames
.slice((page - 1) * MAX_GAMES_PER_PAGE, page * MAX_GAMES_PER_PAGE)
.map((game) => (
<GameCard
key={game['Game Name']}
title={game['Game Name']}
imageUrl={game['Boxart image']}
/>
))}
</div>
</div>
<Pagination
page={page}
disabledNext={filteredGames.length <= page * MAX_GAMES_PER_PAGE}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)
}

View File

@ -14,9 +14,12 @@ import '../styles/settings.css'
import '../styles/styles.css'
import '../styles/write.css'
import { copyTextToClipboard } from '../utils'
import { MetadataController } from '../controllers'
import { useEffect, useState } from 'react'
export const SettingsPage = () => {
const location = useLocation()
const userState = useAppSelector((state) => state.user)
return (
<div className='InnerBodyMain'>
@ -34,7 +37,9 @@ export const SettingsPage = () => {
<PreferencesSetting />
)}
{location.pathname === appRoutes.settingsAdmin && <AdminSetting />}
<ProfileSection />
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
@ -44,8 +49,21 @@ export const SettingsPage = () => {
const SettingTabs = () => {
const location = useLocation()
const [isAdmin, setIsAdmin] = useState(false)
const userState = useAppSelector((state) => state.user)
useEffect(() => {
MetadataController.getInstance().then((controller) => {
if (userState.auth && userState.user?.npub) {
setIsAdmin(
controller.adminNpubs.includes(userState.user.npub as string)
)
} else {
setIsAdmin(false)
}
})
}, [userState])
const handleSignOut = () => {
logout()
}
@ -117,26 +135,28 @@ const SettingTabs = () => {
</svg>
Preference
</Link>
<Link
className={`btn btnMain btnMainAltText btnMainClear ${
location.pathname === appRoutes.settingsAdmin
? 'btnMainClearActive'
: ''
}`}
role='button'
to={appRoutes.settingsAdmin}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -32 576 576'
width='1em'
height='1em'
fill='currentColor'
{isAdmin && (
<Link
className={`btn btnMain btnMainAltText btnMainClear ${
location.pathname === appRoutes.settingsAdmin
? 'btnMainClearActive'
: ''
}`}
role='button'
to={appRoutes.settingsAdmin}
>
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
</svg>
Admin
</Link>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -32 576 576'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
</svg>
Admin
</Link>
)}
</div>
{userState.auth &&
@ -268,9 +288,7 @@ const ProfileSettings = () => {
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
>
<div className='IBMSMSMSSS_Author_Top_IconWrapped'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
@ -279,14 +297,15 @@ const ProfileSettings = () => {
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d="M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z"></path>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>
user bio, this is a long string of temporary text that would be replaced with the user bio from their metada address
user bio, this is a long string of temporary text that would
be replaced with the user bio from their metada address
</p>
<div
id='OwnerFollowLogin-1'

View File

@ -11,7 +11,7 @@ import { ModDetails } from '../types'
import { toast } from 'react-toastify'
import { useState } from 'react'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { useDidMount } from '../hooks'
import { useAppSelector, useDidMount } from '../hooks'
export const SubmitModPage = () => {
const location = useLocation()
@ -19,6 +19,8 @@ export const SubmitModPage = () => {
const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(false)
const userState = useAppSelector((state) => state.user)
const title = location.pathname.startsWith('/edit-mod')
? 'Edit Mod'
: 'Submit a mod'
@ -74,7 +76,9 @@ export const SubmitModPage = () => {
)}
</div>
</div>
<ProfileSection />
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>

View File

@ -1,10 +1,13 @@
import { CheckboxField, InputField } from '../components/Inputs'
import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector } from '../hooks'
import '../styles/innerPage.css'
import '../styles/styles.css'
import '../styles/write.css'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -12,7 +15,9 @@ export const WritePage = () => {
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Write a blog post (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>
Write a blog post (WIP)
</h2>
</div>
<div className='IBMSMSMBS_Write'>
<InputField
@ -58,7 +63,9 @@ export const WritePage = () => {
</div>
</div>
</div>
<ProfileSection />
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>

View File

@ -1,36 +1,48 @@
import { SearchPage } from 'pages/search'
import { AboutPage } from '../pages/about'
import { BlogsPage } from '../pages/blogs'
import { GamesPage } from '../pages/games'
import { HomePage } from '../pages/home'
import { InnerModPage } from '../pages/innerMod'
import { ModPage } from '../pages/mod'
import { ModsPage } from '../pages/mods'
import { ProfilePage } from '../pages/profile'
import { SettingsPage } from '../pages/settings'
import { SubmitModPage } from '../pages/submitMod'
import { WritePage } from '../pages/write'
import { GamePage } from 'pages/game'
export const appRoutes = {
index: '/',
home: '/home',
games: '/games',
game: '/game/:name',
mods: '/mods',
modsInner: '/mods-inner/:naddr',
mod: '/mod/:naddr',
about: '/about',
blog: '/blog',
submitMod: '/submit-mod',
editMod: '/edit-mod/:naddr',
write: '/write',
search: '/search',
settingsProfile: '/settings-profile',
settingsRelays: '/settings-relays',
settingsPreferences: '/settings-preferences',
settingsAdmin: '/settings-admin'
settingsAdmin: '/settings-admin',
profile: '/profile/:nprofile'
}
export const getModsInnerPageRoute = (eventId: string) =>
appRoutes.modsInner.replace(':naddr', eventId)
export const getGamePageRoute = (name: string) =>
appRoutes.game.replace(':name', name)
export const getModPageRoute = (eventId: string) =>
appRoutes.mod.replace(':naddr', eventId)
export const getModsEditPageRoute = (eventId: string) =>
appRoutes.editMod.replace(':naddr', eventId)
export const getProfilePageRoute = (nprofile: string) =>
appRoutes.profile.replace(':nprofile', nprofile)
export const routes = [
{
path: appRoutes.index,
@ -44,13 +56,17 @@ export const routes = [
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.modsInner,
element: <InnerModPage />
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
@ -72,6 +88,10 @@ export const routes = [
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
@ -87,5 +107,9 @@ export const routes = [
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
}
]

View File

@ -1,6 +1,6 @@
.swiper-pagination-bullet-active {
background: rgba(255,255,255,0.5);
box-shadow: 0 0 4px 0 rgba(0,0,0,0.5);
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5);
}
.simple-slider .swiper-slide {
@ -22,16 +22,22 @@
}
}
.simple-slider .swiper-button-next, .simple-slider .swiper-button-prev {
.simple-slider .swiper-button-next,
.simple-slider .swiper-button-prev {
width: 50px;
margin-left: 00px;
margin-right: 00px;
color: rgba(255,255,255,0.5);
background: linear-gradient(rgba(255,255,255,0.05), rgba(255,255,255,0.05)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
color: rgba(255, 255, 255, 0.5);
background: linear-gradient(
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.05)
),
linear-gradient(to top right, #262626, #292929, #262626),
linear-gradient(to top right, #262626, #292929, #262626);
padding: 10px;
height: 75px;
border-radius: 10px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
@ -39,19 +45,27 @@
margin-top: -35px;
}
.simple-slider .swiper-button-next:hover, .simple-slider .swiper-button-prev:hover {
background: linear-gradient(rgba(255,255,255,0.1), rgba(255,255,255,0.1)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
.simple-slider .swiper-button-next:hover,
.simple-slider .swiper-button-prev:hover {
background: linear-gradient(
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.1)
),
linear-gradient(to top right, #262626, #292929, #262626),
linear-gradient(to top right, #262626, #292929, #262626);
}
.swiper-button-next:after, .swiper-button-prev:after {
font-size: 18px;
.swiper-button-next:after,
.swiper-button-prev:after {
font-size: 18px!important;
}
@media (max-width:992px) {
.simple-slider .swiper-button-next, .simple-slider .swiper-button-prev {
@media (max-width: 992px) {
.simple-slider .swiper-button-next,
.simple-slider .swiper-button-prev {
bottom: 0;
top: unset;
width: 48%;
width: 45%;
height: unset;
padding: 10px;
}
@ -88,14 +102,17 @@
@media (max-width: 992px) {
.swiper-slide.IBMSMSliderContainerWrapperSlider {
grid-template-columns: 1.15fr 0.85fr;
padding: 0 0 25px 0;
padding: 0 5px 25px 5px;
grid-gap: 15px;
}
}
@media (max-width: 768px) {
.swiper-slide.IBMSMSliderContainerWrapperSlider {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
grid-gap: 15px;
height: 500px;
}
}
@ -115,7 +132,12 @@
bottom: 0;
right: 0;
left: 0;
background: linear-gradient(rgba(255,255,255,0.15), rgba(255,255,255,0.15)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
background: linear-gradient(
rgba(255, 255, 255, 0.15),
rgba(255, 255, 255, 0.15)
),
linear-gradient(to top right, #262626, #292929, #262626),
linear-gradient(to top right, #262626, #292929, #262626);
z-index: -1;
border-radius: 10px;
}
@ -129,12 +151,15 @@
opacity: 1;
}
.swiper-container-horizontal > .swiper-pagination-bullets, .swiper-pagination-custom, .swiper-pagination-fraction {
.swiper-container-horizontal > .swiper-pagination-bullets,
.swiper-pagination-custom,
.swiper-pagination-fraction {
width: 100%;
bottom: 0;
}
.swiper-button-next, .swiper-button-prev {
.swiper-button-next,
.swiper-button-prev {
position: absolute;
}
@ -161,23 +186,41 @@
.SliderWrapper {
width: 100%;
padding: 50px 0;
background: rgba(0,0,0,0.1);
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: -25px 0 0 0;
border-bottom: solid 1px rgba(255,255,255,0.05);
border-bottom: solid 1px rgba(255, 255, 255, 0.05);
}
.IBMSMSCWSPic {
border-radius: 10px;
overflow: hidden;
border: solid 1px rgba(255,255,255,0.05);
padding-top: 50%;
border: solid 1px rgba(255, 255, 255, 0.05);
z-index: 1;
box-shadow: 0 0 8px 0 rgba(0,0,0,0.25);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.25);
width: 100%;
height: 100%;
object-fit: cover; /* Ensures the image covers the container like a background image */
}
.IBMSMSCWSPicWrapper {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
@media (max-width: 768px) {
.IBMSMSCWSPicWrapper {
height: 300px;
}
}
.IBMSMSCWSInfo {
@ -187,14 +230,20 @@
justify-content: center;
padding: 25px;
border-radius: 10px;
background: linear-gradient(rgba(255,255,255,0), rgba(255,255,255,0)), linear-gradient(to top right, rgb(38,38,38), rgb(41,41,41), rgb(38,38,38));
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
border: solid 1px rgba(255,255,255,0.05);
background: linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0)),
linear-gradient(
to top right,
rgb(38, 38, 38),
rgb(41, 41, 41),
rgb(38, 38, 38)
);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
border: solid 1px rgba(255, 255, 255, 0.05);
}
@media (max-width: 768px) {
.IBMSMSCWSInfo {
/*margin: -25px 10px 0 10px;*/
height: 100%;
}
}
@ -212,19 +261,22 @@
-webkit-line-clamp: 2;
font-size: 20px;
line-height: 1.25;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
font-weight: bold;
}
.IBMSMSCWSInfoTextWrapper {
flex-grow: 1;
}
.IBMSMSCWSInfoText {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 8;
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
line-height: 1.5;
flex-grow: 1;
}
@media (max-width: 576px) {
@ -234,7 +286,16 @@
}
}
.IBMSMSCWSInfoText.IBMSMSCWSInfoText2 {
-webkit-line-clamp: 1;
border-top: solid 1px rgba(255,255,255,0.1);
padding: 10px 0 0 5px;
flex-grow: 0;
}
.swiper-pagination {
display: none;
bottom: -10px !important;
}
@media (max-width: 992px) {
@ -244,7 +305,7 @@
}
.swiper-pagination-bullet {
background: rgba(0,0,0,0.5);
background: rgba(0, 0, 0, 0.5);
opacity: 1;
width: 12px;
height: 12px;
@ -252,6 +313,5 @@
}
.swiper-pagination-bullet.swiper-pagination-bullet-active {
background: rgba(128,0,255,0.5);
background: rgba(128, 0, 255, 0.5);
}

View File

@ -56,6 +56,8 @@
}
.IBMSMSMSSS_Author_Top_Icon {
min-width: 16px;
min-height: 16px;
}
.IBMSMSMSSS_Author_Top_Address {
@ -143,10 +145,11 @@
}
.IBMSMSMSSS_Author_Top_Name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgba(255,255,255,0.75);
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
}
.IBMSMSMSSS_Author_Top_PPWrapper {
@ -158,6 +161,15 @@
.IBMSMSMSSS_Author_Top_Handle {
color: rgba(255,255,255,0.5);
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
}
.IBMSMSMSSS_Author_Top_Handle.IBMSMSMSSS_Author_Top_HandleNomen {
color: #F7931A;
font-weight: bold;
}
.IBMSMSMSSS_Author_Top_NostrLinksLink.IBMSMSMSSS_A_T_NLL_IBMSMSMSSSFollow {

View File

@ -26,6 +26,7 @@
}
.cardBlogMainInside {
transition: ease 0.4s;
position: absolute;
top: 0;
bottom: 0;
@ -36,5 +37,12 @@
justify-content: end;
align-items: start;
padding: 15px;
background: linear-gradient(rgb(0 0 0 / 0%) 0%, rgb(0 0 0 / 75%) 100%);
}
.cardBlogMainInside:hover {
transition: ease 0.4s;
background: linear-gradient(rgb(0 0 0 / 35%) 0%, rgb(0 0 0 / 85%) 100%);
backdrop-filter: blur(5px);
}

View File

@ -17,16 +17,24 @@
transform: scale(1);
}
.cardGameMain {
.cardGameMainWrapper {
position: relative;
padding-top: 150%;
}
.cardGameMain {
border-radius: 15px;
background: rgba(255,255,255,0.05);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
width: 100%;
object-fit: cover; /* Ensures the image covers the container like a background image */
position: absolute;
height: 100%;
top: 0;
}
.cardGameMainTitle {
transition: ease 0.4s;
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
padding: 0 15px;
font-weight: bold;
display: -webkit-box;
@ -35,5 +43,5 @@
-webkit-line-clamp: 1;
font-size: 18px;
line-height: 1.5;
text-align: center;
}

View File

@ -9,11 +9,18 @@
background: linear-gradient(to top right, #262626, #292929, #262626);
}
.cMMPicture {
.cMMPictureWrapper {
position: relative;
width: 100%;
padding-top: 56.25%;
background: rgba(0, 0, 0, 0.1);
}
.cMMPicture {
position: absolute;
width: 100%;
height: 100%;
top: 0;
object-fit: cover; /* Ensures the image covers the container like a background image */
}
.cMMBody {
@ -99,12 +106,26 @@
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
line-height: 1.5;
}
.cMMBodyGame {
border-radius: 5px;
padding: 5px 10px;
flex-direction: row;
justify-content: start;
align-items: center;
font-size: 14px;
background: rgba(255,255,255,0.05);
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
}
.cMMFootReactions {
display: flex;
flex-direction: row;

View File

@ -31,7 +31,7 @@
border-radius: 10px;
width: 60px;
height: 60px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
}
.IBMSMSMBSSCL_CommentTopDetails {
@ -42,11 +42,13 @@
.IBMSMSMBSSCL_CommentBottom {
padding: 20px;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
background: linear-gradient(to top right, #262626, #292929, #262626);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
border-radius: 10px;
/*border: solid 1px rgba(255,255,255,0.1);*/
display: flex;
flex-direction: column;
grid-gap: 5px;
}
.IBMSMSMBSSCL_CommentTopPPWrapper {
@ -59,6 +61,20 @@
.IBMSMSMBSSCL_CBText {
}
.IBMSMSMBSSCL_CBTextStatus {
display: flex;
flex-direction: row;
grid-gap: 0px;
border-radius: 4px;
border: solid 1px rgba(255, 255, 255, 0.1);
padding: 5px 10px;
}
.IBMSMSMBSSCL_CBTextStatusSpan {
font-weight: 600;
margin-right: 5px;
}
.IBMSMSMBSSCL_CommentActions {
margin: -10px 0 0 0;
display: grid;
@ -83,7 +99,7 @@
grid-gap: 10px;
padding: 5px 15px;
border-radius: 10px;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
font-weight: bold;
position: relative;
cursor: pointer;
@ -118,20 +134,20 @@
left: 0;
z-index: -1;
border-radius: 10px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
}
.IBMSMSMBSSCL_CAElementText {
}
.IBMSMSMBSSCL_CAElementIcon {
background: rgba(255,255,255,0);
background: rgba(255, 255, 255, 0);
font-size: 14px;
}
.IBMSMSMBSSCL_CTD_Name {
font-weight: bold;
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@ -139,7 +155,7 @@
}
.IBMSMSMBSSCL_CTD_Address {
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@ -147,42 +163,42 @@
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReply {
border: solid 1px rgba(255,255,255,0.05);
border: solid 1px rgba(255, 255, 255, 0.05);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReply:hover {
transition: ease 0.4s;
border: solid 1px rgba(255,255,255,0.05);
color: rgba(255,255,255,0.5);
border: solid 1px rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReplies:hover {
transition: ease 0.4s;
color: rgba(173,90,255,0.75);
color: rgba(173, 90, 255, 0.75);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAERepost.IBMSMSMBSSCL_CAERepostActive {
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAERepost:hover {
transition: ease 0.4s;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEDown:hover {
transition: ease 0.4s;
color: rgba(255,114,54,0.85);
color: rgba(255, 114, 54, 0.85);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEUp:hover {
transition: ease 0.4s;
color: rgba(255,70,70,0.85);
color: rgba(255, 70, 70, 0.85);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEBolt:hover {
transition: ease 0.4s;
color: rgba(255,255,0,0.85);
color: rgba(255, 255, 0, 0.85);
}
.IBMSMSMBSSCL_CAElement:hover {
@ -196,11 +212,15 @@
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEUp.IBMSMSMBSSCL_CAEUpActive {
color: rgba(255,70,70,0.85);
color: rgba(255, 70, 70, 0.85);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEDown.IBMSMSMBSSCL_CAEDownActive {
color: rgba(255, 114, 54, 0.85);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEBolt.IBMSMSMBSSCL_CAEBoltActive {
color: rgba(255,255,0,0.85);
color: rgba(255, 255, 0, 0.85);
}
.IBMSMSMBSSCL_CommentActionsInside {
@ -212,7 +232,7 @@
}
.IBMSMSMBSSCL_CommentActionsDetails {
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
font-size: 16px;
display: flex;
flex-direction: column;
@ -233,12 +253,12 @@
.IBMSMSMBSSCL_CADDate {
transition: ease 0.4s;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
}
.IBMSMSMBSSCL_CADTime {
transition: ease 0.4s;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
}
.IBMSMSMBSSCL_CommentTopDetailsWrapper {
@ -277,16 +297,16 @@
.IBMSMSMBSSCC_Top_Box {
transition: border, background, box-shadow ease 0.4s;
width: 100%;
background: rgba(0,0,0,0.05);
border: solid 1px rgba(255,255,255,0.05);
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1);
background: rgba(0, 0, 0, 0.05);
border: solid 1px rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 8px 0 rgb(0, 0, 0, 0.1);
border-radius: 10px;
min-height: 100px;
height: 100px;
min-width: 100%;
outline: unset;
padding: 15px 20px;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
}
@media (max-width: 576px) {
@ -296,36 +316,37 @@
}
}
.IBMSMSMBSSCC_Top_Box:focus, hover {
.IBMSMSMBSSCC_Top_Box:focus,
hover {
transition: border, background, box-shadow ease 0.4s;
background: rgba(0,0,0,0.1);
border: solid 1px rgba(255,255,255,0.1);
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.15);
background: rgba(0, 0, 0, 0.1);
border: solid 1px rgba(255, 255, 255, 0.1);
box-shadow: inset 0 0 8px 0 rgb(0, 0, 0, 0.15);
outline: unset;
}
.IBMSMSMBSSCC_BottomButton {
transition: ease 0.4s;
text-decoration: unset;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
font-weight: bold;
padding: 10px 20px;
border-radius: 10px;
box-shadow: 0 0 8px 0 rgba(0,0,0,0);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0);
font-size: 16px;
transform: scale(1);
position: relative;
cursor: pointer;
border: solid 1px rgba(255,255,255,0.1);
border: solid 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.IBMSMSMBSSCC_BottomButton:hover {
transition: ease 0.4s;
text-decoration: unset;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
border-radius: 10px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
font-size: 16px;
transform: scale(1.03);
/*border: solid 1px rgba(255,255,255,0);*/
@ -370,9 +391,9 @@
display: flex;
flex-direction: row;
border-radius: 10px;
border: solid 1px rgba(255,255,255,0.1);
border: solid 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
font-size: 14px;
}
@ -389,14 +410,14 @@
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.25);
}
.IBMSMSMBSSCL_CTOLink:hover {
transition: ease 0.4s;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.5);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
}
.IBMSMSMBSSCL_CTOLink:active > .IBMSMSMBSSCL_CTOLinkIcon {
@ -439,7 +460,7 @@
}
.btnMain.CommentsToggleBtn.CommentsToggleActive {
background: rgba(255,255,255,0.1);
background: rgba(255, 255, 255, 0.1);
font-weight: bold;
}
@ -450,11 +471,11 @@
}
.IBMSMSMBSSTitle {
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
}
.IBMSMSMBSSCL_CommentNoteRepliesTitle {
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
}
.IBMSMSMBSSCL_CAElementLoadWrapper {
@ -468,7 +489,7 @@
}
.IBMSMSMBSSCL_CAElementLoad {
background: rgba(255,255,255,0.5);
background: rgba(255, 255, 255, 0.5);
width: 0%;
}
@ -476,4 +497,3 @@
padding: 5px 10px;
height: 100%;
}

View File

@ -4,7 +4,7 @@
grid-gap: 25px;
}
@media (max-width: 992px) {
@media (max-width: 1200px) {
.IBMSMSplitMain {
display: flex;
flex-direction: column;

View File

@ -281,7 +281,7 @@
.NavMainBottomInsideOther {
display: flex;
lex-direction: row;
flex-direction: row;
grid-gap: 10px;
height: 100%;
justify-content: end;
@ -308,6 +308,8 @@
}
.NavMainBottomInsideOtherLeft {
display: flex;
justify-content: start;
@media (max-width: 768px) {
display: none;
@ -315,7 +317,8 @@
}
.NavMainBottomInsideOtherRight {
display: flex;
justify-content: end;
}
}
}

View File

@ -43,6 +43,7 @@
border: solid 1px rgba(255, 255, 255, 0);
color: rgba(255, 255, 255, 0.1);
font-weight: bold;
white-space: nowrap;
}
.PaginationMainInsideBox.PaginationMainInsideBoxArrows {
@ -93,8 +94,15 @@
.PaginationMainInsideBoxGroup {
display: flex;
flex-direction: row;
justify-content: center;
justify-content: start;
grid-gap: 10px;
overflow: auto;
max-width: 470px;
height: 47px;
}
.PaginationMainInsideBoxGroup::-webkit-scrollbar {
display: none;
}
@media (max-width: 768px) {
@ -102,5 +110,6 @@
width: 100%;
order: 1;
justify-content: space-around;
max-width: unset;
}
}

View File

@ -53,7 +53,6 @@
align-items: center;
padding: 25px;
overflow: auto;
max-height: 70vh;
}
.popUpMainCardBottomQR {

View File

@ -40,6 +40,10 @@
overflow: hidden;
}
.IBMSMSMBSSPostBody > div {
width: 100%;
}
.IBMSMSMBSSPostTitleHeading {
width: 100%;
}

121
src/styles/socialNav.css Normal file
View File

@ -0,0 +1,121 @@
.socialNav {
transition: ease 0.4s;
position: fixed;
bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 10px;
right: 50%;
transform: translateX(50%);
}
@media (max-width: 576px) {
.socialNav {
transition: ease 0.4s;
right: 100%;
transform: translateX(100%);
width: 100%;
align-items: end;
}
}
.socialNavInside {
width: 100%;
padding: 10px;
border-radius: 15px;
Background: linear-gradient(to top right, rgba(27,27,27,0.9), rgba(35,35,35,0.9), rgba(27,27,27,0.9));
box-shadow: 0 0 8px rgba(0,0,0,0.2);
backdrop-filter: blur(5px);
display: flex;
flex-direction: row;
grid-gap: 5px;
border: solid 2px rgba(255,255,255,0.05);
overflow-x: auto;
max-width: 80vw;
}
@media (max-width: 576px) {
.socialNavInside {
max-width: unset;
}
}
.socialNavInside::-webkit-scrollbar {
display: none;
}
.btnMain.socialNavInsideBtn {
transition: ease 0.4s;
padding: 0 15px;
font-size: 24px;
height: 45px;
width: 55px;
border-radius: 10px;
Background: linear-gradient(to top right, rgba(50,50,50,0), rgba(55,55,55,0), rgba(50,50,50,0));
color: rgba(255,255,255,0.25);
}
.btnMain.socialNavInsideBtn:hover {
transition: ease 0.4s;
background: #434343;
}
.btnMain.socialNavInsideBtn.socialNavInsideBtnActive {
Background: #434343;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
color: rgba(255,255,255,0.75);
}
.socialNavInsideWrapper {
margin: 10px 0 15px 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
grid-gap: 5px;
}
@media (max-width: 576px) {
.socialNavInsideWrapper {
width: 100%;
justify-content: end;
}
}
.socialNavCollapse {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
Background: linear-gradient(to top right, rgba(27,27,27,0.75), rgba(35,35,35,0.75), rgba(27,27,27,0.75));
box-shadow: 0 0 8px rgba(0,0,0,0.2);
padding: 15px 5px;
border-radius: 5px;
border: solid 1px rgba(255,255,255,0.05);
backdrop-filter: blur(5px);
cursor: pointer;
transform: scale(1);
color: rgba(255,255,255,0.75);
}
.socialNavCollapse:hover {
Background: linear-gradient(rgba(255,255,255,0.01), rgba(255,255,255,0.01)), linear-gradient(to right top, rgba(27,27,27,0.75), rgba(35,35,35,0.75), rgba(27,27,27,0.75));
color: rgb(255,255,255);
}
.socialNavCollapseIcon {
transition: ease 0.4s;
}
.btnMain.socialNavInsideBtn::before {
background: linear-gradient(rgba(255,255,255,0.05), rgba(255,255,255,0.05)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
opacity: 0;
}
.btnMain.socialNavInsideBtn:hover::before {
opacity: 1;
}

View File

@ -47,27 +47,27 @@ h6 {
}
h1 {
font-size: 38px
font-size: 38px;
}
h2 {
font-size: 32px
font-size: 32px;
}
h3 {
font-size: 24px
font-size: 24px;
}
h4 {
font-size: 20px
font-size: 20px;
}
h5 {
font-size: 18px
font-size: 18px;
}
h6 {
font-size: 16px
font-size: 16px;
}
.IBMSecMain {
@ -273,29 +273,28 @@ h6 {
/* the 4 classes below here are a temp fix for the games dropdown stylings */
.dropdownMainMenu.dropdownMainMenuAlt {
max-height: unset!important;
max-height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div {
height: unset!important;
height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
height: unset!important;
width: 100%!important;
display: flex;
flex-direction: column;
gap: 5px;
max-height: 300px;
overflow: auto;
padding: 5px;
height: unset !important;
width: 100% !important;
display: flex;
flex-direction: column;
gap: 5px;
max-height: 300px;
overflow: auto;
padding: 5px;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
position: relative!important;
left: unset!important;
top: unset!important;
position: relative !important;
left: unset !important;
top: unset !important;
}
.dropdownMainMenuItem {
@ -346,6 +345,38 @@ h6 {
border: solid 1px rgba(255, 255, 255, 0.2);
}
.ProseMirror-focused {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.control-group {
display: flex;
justify-content: center;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button-group button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.button-group button.is-active {
background-color: rgb(255 255 255 / 15%);
color: rgb(255 255 255 / 85%);
font-weight: bold;
transform: scale(1);
}
.labelMain {
margin: 0;
color: rgba(255, 255, 255, 0.5);
@ -624,3 +655,28 @@ a:hover {
.errorMainText {
}
.spinner {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.spinnerCircle {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -27,6 +27,7 @@
color: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.05);
text-decoration: unset;
}
.IBMSMSMBSSTagsTag:active {

104
src/styles/tiptap.scss Normal file
View File

@ -0,0 +1,104 @@
/* Basic editor styles */
.tiptap {
/* List styles */
p {
margin: 5px 0px;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin: 10px 0px;
text-wrap: pretty;
}
h1,
h2 {
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
code {
background-color: var(--purple-light); // todo: fix the color
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black); // todo: fix the color
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
background: #00000030;
border-radius: 5px;
border: solid 2px rebeccapurple;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-radius: 0 10px 10px 0;
border-left: solid 6px rgba(255, 255, 255, 0.1);
padding: 25px;
background: #232323;
color: rgba(255, 255, 255, 0.75);
margin: 10px 0;
}
}
/* Toolbar Styling */
.control-group {
padding: 5px 0px 15px 0px;
border-radius: 0px;
border-bottom: solid 1px rgb(255 255 255 / 10%);
}
.ProseMirror {
min-height: 75px;
}
.btnMain.btnMainTipTap {
padding: 5px 10px;
height: 35px;
font-size: 14px;
}

View File

@ -1,3 +1,9 @@
export type Game = {
'Game Name': string
'16 by 9 image': string
'Boxart image': string
}
export interface ModFormState {
dTag: string
aTag: string
@ -32,5 +38,5 @@ export interface ModDetails extends Omit<ModFormState, 'tags'> {
export interface MuteLists {
authors: string[]
eventIds: string[]
replaceableEvents: string[]
}

View File

@ -2,3 +2,4 @@ export * from './mod'
export * from './nostr'
export * from './url'
export * from './utils'
export * from './zap'

View File

@ -134,6 +134,13 @@ export const initializeFormState = (
]
})
interface FetchModsOptions {
source?: string
until?: number
since?: number
limit?: number
}
/**
* Fetches a list of mods based on the provided source.
*
@ -144,15 +151,16 @@ export const initializeFormState = (
* @returns A promise that resolves to an array of `ModDetails` objects. In case of an error,
* it logs the error and shows a notification, then returns an empty array.
*/
export const fetchMods = async (
source: string,
until?: number,
since?: number
): Promise<ModDetails[]> => {
export const fetchMods = async ({
source,
until,
since,
limit
}: FetchModsOptions): Promise<ModDetails[]> => {
// Define the filter criteria for fetching mods
const filter: Filter = {
kinds: [kinds.ClassifiedListing], // Specify the kind of events to fetch
limit: MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
'#t': [T_TAG_VALUE],
until, // Optional filter to fetch events until this timestamp
since // Optional filter to fetch events from this timestamp
@ -167,8 +175,6 @@ export const fetchMods = async (
return RelayController.getInstance()
.fetchEvents(filter, []) // Pass the filter and an empty array of options
.then((events) => {
console.log('events :>> ', events)
// Convert the fetched events into a list of mods
const modList = constructModListFromEvents(events)
return modList // Return the list of mods

View File

@ -1,4 +1,16 @@
import { nip19, Event } from 'nostr-tools'
import {
Event,
finalizeEvent,
generateSecretKey,
getPublicKey,
kinds,
nip04,
nip19,
UnsignedEvent
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { RelayController } from '../controllers'
import { log, LogType } from './utils'
/**
* Get the current time in seconds since the Unix epoch (January 1, 1970).
@ -92,32 +104,118 @@ export const npubToHex = (pubKey: string): string | null => {
* @returns The zap amount in the form of a number, converted from the extracted data, or 0 if the amount cannot be determined.
*/
export const extractZapAmount = (event: Event): number => {
// Find the 'description' tag within the event's tags
const description = event.tags.find(
(tag) => tag[0] === 'description' && typeof tag[1] === 'string'
// Find the 'amount' tag within the parsed description's tags
const amountTag = event.tags.find(
(tag) => tag[0] === 'amount' && typeof tag[1] === 'string'
)
// If the 'description' tag is found and it has a valid value
if (description && description[1]) {
try {
// Parse the description as JSON to get additional details
const parsedDescription: Event = JSON.parse(description[1])
// Find the 'amount' tag within the parsed description's tags
const amountTag = parsedDescription.tags.find(
(tag) => tag[0] === 'amount' && typeof tag[1] === 'string'
)
// If the 'amount' tag is found and it has a valid value, convert it to an integer and return
if (amountTag && amountTag[1]) return parseInt(amountTag[1]) / 1000
} catch (error) {
// Log an error message if JSON parsing fails
console.log(
`An error occurred while parsing description of zap event: ${error}`
)
}
}
// If the 'amount' tag is found and it has a valid value, convert it to an integer and return
if (amountTag && amountTag[1]) return parseInt(amountTag[1]) / 1000
// Return 0 if the zap amount cannot be determined
return 0
}
/**
* Signs and publishes an event to user's relays.
*
* @param unsignedEvent - The event object which needs to be signed before publishing.
* @returns - A promise that resolves to boolean indicating whether the event was successfully signed and published
*/
export const signAndPublish = async (unsignedEvent: UnsignedEvent) => {
// Sign the event. This returns a signed event or null if signing fails.
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
// If signing the event fails, display an error toast and log the error.
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
// If the event couldn't be signed, exit the function and return null.
if (!signedEvent) return false
// Publish the signed event to the relays using the RelayController.
// This returns an array of relay URLs where the event was successfully published.
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {
// Display an error toast if the event could not be published to any relay.
toast.error('Failed to publish event on any relay')
return false
}
// Display a success toast with the list of relays where the event was successfully published.
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
return true
}
/**
* Sends an encrypted direct message (DM) to a receiver using a randomly generated secret key.
*
* @param message - The plaintext message content to be sent.
* @param receiver - The public key of the receiver to whom the message is being sent.
* @returns - A promise that resolves to true if the message was successfully sent, or false if an error occurred.
*/
export const sendDMUsingRandomKey = async (
message: string,
receiver: string
) => {
// Generate a random secret key for encrypting the message
const secretKey = generateSecretKey()
// Encrypt the message using the generated secret key and the receiver's public key
const encryptedMessage = await nip04
.encrypt(secretKey, receiver, message)
.catch((err) => {
// If encryption fails, display an error toast
toast.error(
`An error occurred in encrypting message content: ${err.message || err}`
)
return null
})
// If encryption failed, exit the function and return false
if (!encryptedMessage) return false
// Construct the unsigned event containing the encrypted message and relevant metadata
const unsignedEvent: UnsignedEvent = {
pubkey: getPublicKey(secretKey),
kind: kinds.EncryptedDirectMessage,
created_at: now(),
tags: [['p', receiver]],
content: encryptedMessage
}
// Finalize and sign the event using the generated secret key
const signedEvent = finalizeEvent(unsignedEvent, secretKey)
// Publish the signed event (the encrypted DM) to the relays
const publishedOnRelays = await RelayController.getInstance().publishDM(
signedEvent,
receiver
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {
// Display an error toast if the event could not be published to any relay
toast.error('Failed to publish encrypted direct message on any relay')
return false
}
// Display a success toast if the event was successfully published to one or more relays
toast.success(`Report successfully submitted!`)
// Return true indicating that the DM was successfully sent
return true
}

View File

@ -123,3 +123,15 @@ export const abbreviateNumber = (value: number): string => {
return value.toString()
}
}
export const handleGameImageError = (
e: React.SyntheticEvent<HTMLImageElement, Event>
) => {
e.currentTarget.src = import.meta.env.VITE_FALLBACK_GAME_IMAGE
}
export const handleModImageError = (
e: React.SyntheticEvent<HTMLImageElement, Event>
) => {
e.currentTarget.src = import.meta.env.VITE_FALLBACK_MOD_IMAGE
}

38
src/utils/zap.ts Normal file
View File

@ -0,0 +1,38 @@
import { ZapReceipt, ZapRequest } from 'types'
/**
* Gets value of description tag.
* @param receipt - zap receipt.
* @returns value of description tag.
*/
export const getDescription = (receipt: ZapReceipt) => {
return receipt.tags.filter((tag) => tag[0] === 'description')[0][1]
}
/**
* Gets value of amount tag.
* @param request - zap receipt.
* @returns value of amount tag.
*/
export const getAmount = (request: ZapRequest) => {
return request.tags.filter((tag) => tag[0] === 'amount')[0][1]
}
/**
* Gets zap amount.
* @param receipt - zap receipt.
* @returns zap amount
*/
export const getZapAmount = (receipt: ZapReceipt) => {
const description = getDescription(receipt)
let request: ZapRequest
try {
request = JSON.parse(description)
} catch (err) {
throw 'An error occurred in parsing description tag from zapReceipt'
}
// Zap amount is stored in mili sats, to get the zap amount we'll divide it by 1000
return parseInt(getAmount(request)) / 1000
}

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

@ -3,6 +3,9 @@
interface ImportMetaEnv {
readonly VITE_APP_RELAY: string
readonly VITE_ADMIN_NPUBS: string
readonly VITE_REPORTING_NPUB: string
readonly VITE_FALLBACK_MOD_IMAGE: string
readonly VITE_FALLBACK_GAME_IMAGE: string
// more env variables...
}

View File

@ -23,7 +23,11 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"plugins": [{ "name": "ts-css-modules-vite-plugin" }]
"plugins": [{ "name": "ts-css-modules-vite-plugin" }],
"paths": {
"*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -1,7 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tsconfigPaths()]
})