Compare commits

...

253 Commits
zap ... staging

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
daniyal
6377da94c6 fix: use naddr instead of nevent 2024-08-26 17:11:33 +05: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
b0f8c647ee Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-26 09:51:20 +00:00
b259623ab6 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 17s
2024-08-26 09:46:18 +00:00
2a28b068b6 Update src/layout/header.tsx
Some checks failed
Release to Staging / build_and_release (push) Failing after 18s
2024-08-26 09:39:14 +00:00
e335b05290 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-26 08:27:38 +00:00
b1b0876238 Update src/styles/nav.module.scss
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-08-26 08:25:20 +00:00
d18528bcf6 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-26 08:20:45 +00:00
7743f30faa Update src/styles/nav.module.scss
Some checks failed
Release to Staging / build_and_release (push) Failing after 25s
2024-08-26 08:14:44 +00:00
8bd4373546 Update src/styles/nav.module.scss
Some checks failed
Release to Staging / build_and_release (push) Failing after 21s
2024-08-26 08:06:25 +00:00
9a1cc39027 Update src/styles/nav.module.scss
Some checks failed
Release to Staging / build_and_release (push) Failing after 21s
2024-08-26 07:55:53 +00:00
1e6b3d2c0b slightly adjusted structure of nav with new links
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-08-26 07:51:18 +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
47ce263c37 Update src/styles/popup.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-08-21 19:00:42 +00:00
7b25e7653c background color change
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-08-21 18:59:44 +00:00
4d7e69b089 background color change
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-08-21 18:51:22 +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
42d20857f5 color adjustment
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-21 05:12:04 +00:00
837be77d9c color adjustment
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-21 05:10:00 +00:00
d83f19b1ca color adjustment
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-21 05:07:47 +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
a5fe2c1622 Update index.html
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-20 12:33:57 +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
54911d3efa removed comments
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-19 14:37:46 +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
1f93e9c6b6 temp fix for blockquote style
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-19 10:44:36 +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
a5627000ae Upload files to "public/assets"
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-19 09:00:53 +00:00
e40c900499 Update src/styles/cardMod.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-19 08:32:40 +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
021b3fcece Update README.md
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-19 07:34:48 +00:00
40630a6513 Add LICENSE.txt
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-19 07:11:53 +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
daniyal
da88a969f3 fix routing for mod edit page
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-19 11:41:11 +05: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
daniyal
012e868ad3 remove url reachability check for download url as it may give cors error
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-19 11:22:55 +05: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
3ddb95fda2 text edit
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-18 23:34:22 +00:00
434fbd99c0 adjusted registration popup buttons/links
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-18 23:32:47 +00:00
d7246e3cb0 link edit
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-18 19:08:27 +00:00
d425b9ec87 text edits
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-18 19:05:06 +00:00
d462c9bd93 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-18 18:43:37 +00:00
3440cc0fb6 text edits
Some checks failed
Release to Staging / build_and_release (push) Failing after 18s
2024-08-18 18:38:36 +00:00
2222c78742 text edit
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-18 18:21:04 +00:00
02843028f7 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
2024-08-18 17:22:11 +00:00
fd5a9e7e4f Update src/index.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-18 10:30:41 +00:00
e4c09399ec Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-18 10:25:15 +00:00
50de8ef84c headings font size changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-18 10:19:16 +00:00
21ef8c61f4 Update public/.well-known/nostr.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-18 09:07:23 +00:00
0211e380a0 color adjustment
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-17 15:40:35 +00:00
8395d4fc31 text change
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-17 14:31:40 +00:00
b73f3632df font-size change
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-17 14:30:30 +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
b
81fa41635c fix: removing old well known, updating pipeline
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-17 14:50:22 +01:00
b
c0eab5dd13 fix: well-known setup
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-17 14:46:30 +01:00
9cea402479 added the .well-know/nostr.json to the public folder
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-16 19:18:31 +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
acfdd21c4d added to manage nip-05 addresses for the domain
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 19:06:23 +00:00
a7b110503f added link
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 19:02:21 +00:00
a26453c16f new classes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 18:59:43 +00:00
658cf0ab23 text fix + added a new elements for silent payment donation address
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-16 18:57:23 +00:00
8e791075da text fix + added a new elements for silent payment donation address
Some checks failed
Release to Staging / build_and_release (push) Failing after 18s
2024-08-16 18:54:51 +00:00
b9799c3924 Delete public/assets/img/ProfileLinkQR.png
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 18:05:29 +00:00
a4789e7f5f Delete public/assets/img/media-cache (1).jpg
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
2024-08-16 18:05:19 +00:00
3d230eddf7 Delete public/assets/img/media-cache (4).png
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
2024-08-16 18:05:04 +00:00
241a1dcec9 Delete public/assets/img/uBlog Logo.svg
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
2024-08-16 18:04:47 +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
cc31325bb7 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 15:35:08 +00:00
f9498d8d8f test temp fix for game list ux in game submission
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-16 15:30:23 +00:00
3b594fb5eb test temp fix for game list ux in game submission
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 15:27:37 +00:00
b5fd5ded6a fixed an issue that was preventing the games list from visually appearing
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-16 14:32:49 +00:00
999301a4d9 created a new class to handle input dropdown so it doesn't overflow
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-16 14:29:11 +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
s
2cd972f0e1 Update .gitea/workflows/release-production.yaml
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 12:48:13 +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
fc8bd16995 link hover effect
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 12:21:00 +00:00
bdd44681d7 link styles
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 12:18:28 +00:00
8e53b8af87 to affect style of links on hover of their parent
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 12:15:02 +00:00
dd22b780a7 Update src/components/Banner.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 12:09:12 +00:00
1d04002b8d style change
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-08-16 12:05:47 +00:00
f4ced90acb temp styling
Some checks failed
Release to Staging / build_and_release (push) Failing after 19s
2024-08-16 12:04:43 +00:00
90581fe630 link and text changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 12:01:15 +00:00
5771f81061 link and text changes
Some checks failed
Release to Staging / build_and_release (push) Failing after 18s
2024-08-16 11:47:54 +00:00
38f46bf01d link and text changes
Some checks failed
Release to Staging / build_and_release (push) Failing after 18s
2024-08-16 11:18:47 +00:00
0f688b2a59 img fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 11:09:10 +00:00
a88a2d93b0 text change
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-16 11:03:48 +00:00
b6f5dfa720 text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 11:02:39 +00:00
5d890d86f6 media change
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-16 10:58:30 +00:00
a05c2e0164 removed Amber option in register popup
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 10:55:05 +00:00
d62b9b1c20 link to div and icon change
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-16 10:50:03 +00:00
ed8ff79373 text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 10:46:21 +00:00
c01f3279cc text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 10:39:08 +00:00
8191534cf2 text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-16 10:27:20 +00:00
1eb25d1e72 text changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-16 10:23:32 +00:00
f35ebe4bee text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-16 10:22:22 +00:00
83cc74606c text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-16 10:14:12 +00:00
a0d2dd48e9 text and media changes
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
2024-08-16 10:12:22 +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
daniyal
6152b895bc chore: add production release workflow
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-16 11:15:53 +05:00
daniyal
75d8839330 feat: add register popup
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-15 19:31:39 +05:00
daniyal
f544d8ab15 feat: update login methods in nostr-login configuration
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-14 21:33:32 +05:00
daniyal
c978c50168 fix: add class bodyMain to root element
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-14 15:17:37 +05:00
daniyal
36f9f976ac feat: fix download flow
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 15:06:13 +05:00
daniyal
62a8207bcd feat: export nsec
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-14 14:50:28 +05:00
0c74bfbb58 removed link
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 09:28:06 +00:00
144a091b7f icon fixes and removal of profile link (since there already is one above it)
Some checks failed
Release to Staging / build_and_release (push) Failing after 21s
2024-08-14 09:24:59 +00:00
0384dc668e swapped an icon
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-14 09:20:44 +00:00
929800a543 fixed a overflow issues (by adding padding)
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 09:17:56 +00:00
a68a906cbe fixed spacing issue
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 09:08:30 +00:00
6c6381791e Made the social posts scrollable with a bit of redesign
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-14 08:59:49 +00:00
9c2ec3a5ea added wrapping div .IBMSMSplitMainSmallSideSecWrapper
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 08:41:43 +00:00
563119e1a1 Added position: relative; to aid with the sticky position of .IBMSMSplitMainSmallSideSecWrapper
All checks were successful
Release to Staging / build_and_release (push) Successful in 40s
2024-08-14 08:20:15 +00:00
d22b007fd9 added a new class for side column to make it sticky
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
all divs with .IBMSMSplitMainSmallSideSec should have a div wrapped around them with .IBMSMSplitMainSmallSideSecWrapper
2024-08-14 08:16:17 +00:00
daniyal
6acf4c3028 feat: display extra auth details along with downdload links
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
2024-08-14 12:08:58 +05:00
7fa59d6a52 added white background to the QR code for zaps
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
(reason: a lot of the time the camera can't read the QR because it was almost black on black)
2024-08-13 19:00:14 +00:00
f3dde26d88 fixed an overflow issue
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-13 18:53:05 +00:00
daniyal
0cc0d82e68 feat: sanitizeAndAddTargetBlank to body of mod
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-13 23:27:49 +05:00
daniyal
01af3fa72e feat: show time of publishing/editing 2024-08-13 23:15:45 +05:00
daniyal
e06cec84fb fix: month format of publish and updated date fixed
All checks were successful
Release to Staging / build_and_release (push) Successful in 41s
2024-08-13 18:22:25 +05:00
daniyal
ff737e5486 fix: removed removed optional lnurl from tags of payment zap request 2024-08-13 18:13:30 +05:00
daniyal
51bd5a4d5e fix: replace browser router with hash router 2024-08-13 16:49:11 +05:00
daniyal
a8a2d3dbf3 feat: implemented the logic for zapping mod
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-13 15:51:05 +05:00
42c40c2d8e logo updated (for favicon)
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-11 07:00:37 +00:00
f9735128c7 Updated logo
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2024-08-11 06:56:09 +00:00
daniyal
9e4b9472ee chore: fix workflow
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
2024-08-10 00:19:37 +05:00
daniyal
f1f75a42df chore: debug metadata controller
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
2024-08-10 00:09:21 +05:00
daniyal
a4c2b50a16 chore: debug metadata controller
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2024-08-10 00:04:14 +05:00
daniyal
0668226df1 chore(debug): debugging metadata contoller
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
2024-08-09 15:50:34 +05:00
daniyal
d451eb129d chore(refactor): code refactored for zapping app account
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2024-08-08 23:01:50 +05:00
daniyal
5796bb18e3 chore(workflow): set target branch to master
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
2024-08-08 22:32:59 +05:00
daniyal
4b6b0b73d6 chore: fix build errors 2024-08-08 22:28:28 +05:00
daniyal
f06801d80b chore: add workflow for deploying to staging 2024-08-08 22:17:51 +05:00
s
0591dfecd6 Merge pull request 'feat: add the feature to zap admin/app account' (#4) from zap into master
Reviewed-on: #4
2024-08-08 17:02:12 +00:00
87 changed files with 8900 additions and 2778 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

@ -0,0 +1,40 @@
name: Release to Staging
on:
push:
branches:
- master
jobs:
build_and_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 18
- name: Install Dependencies
run: npm ci
- name: Create .env File
run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_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
run: npm run build
- name: Release Build
run: |
npm -g install cloudron-surfer
surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com
surfer put dist/* / --all -d
surfer put dist/.well-known / --all

View File

@ -0,0 +1,40 @@
name: Release to Staging
on:
push:
branches:
- staging
jobs:
build_and_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 18
- name: Install Dependencies
run: npm ci
- name: Create .env File
run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_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
run: npm run build
- name: Release Build
run: |
npm -g install cloudron-surfer
surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server dev.degmods.com
surfer put dist/* / --all -d
surfer put dist/.well-known / --all

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Freakoverse
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,11 +1,91 @@
### Local testing
[DEG Mods](https://degmods.com)
===============================
- clone the repo
**DEG Mods** (Decentralized Game Mods) is a censorship-resistant platform for game mods, built on the Nostr protocol. It allows creators to thrive without the fear of censorship, bans, or losing their connection with fans. At DEG Mods, game mod creators and enthusiasts are empowered because, well, we literally can't mess with them.
```sh
npm ci
```
Features
--------
```sh
npm run dev
```
- **Never Get Your Game Mod Page Taken Down**: Enjoy peace of mind knowing that your content won't be removed or censored.
- **Never Get Your Profile Banned**: Your profile remains active and accessible, without fear of bans.
- **Never Lose Your Followers**: Maintain a steady connection with your followers and fans.
- **Get 100% of Tips Revenue**: Keep all the revenue from tips without any cuts.
Installation
------------
To get started with DEG Mods, follow these steps:
1. **Clone the repository**:
bash
Copy code
`git clone <URL-of-repo>`
2. **Navigate to the project directory**:
bash
Copy code
`cd <project-directory>`
3. **Install dependencies**:
DEG Mods is a React project, so use `npm ci` to install the exact versions of dependencies as specified in `package-lock.json`:
bash
Copy code
`npm ci`
Usage
-----
To run DEG Mods, follow these instructions:
1. **Start the development server**:
bash
Copy code
`npm run dev`
Contributing
------------
We welcome contributions from the community! To contribute to DEG Mods:
- **Submit Issues**: Report bugs, provide feedback, or request features.
- **Submit Pull Requests**: Contribute code improvements or new features.
Screenshots
-----------
Here are some screenshots of DEG Mods in action:
- ![Screenshot 1](https://image.nostr.build/9ea5f70a647581e5ea8855edcc2afb59b33195a3498c4052fa6350e414c03c50.jpg)
- ![Screenshot 2](https://image.nostr.build/f9a74d52547e84e51934f03358e5c41a2f99b896f1b2369fc3f4027d0f1a0d6f.jpg)
- ![Screenshot 3](https://image.nostr.build/cbd806c58a0f5e33b721cd9205c53aae64a422f271a8434b4d9bb8c3ba4e5c90.jpg)
- ![Screenshot 4](https://image.nostr.build/374bb4ca83211fd1f5646b611af188a424ebb1b44df1cb7ad29208868ac12675.jpg)
Nostr Implementations
---------------------
- ✅ **Text**
- ⬜ **Text**
License
-------
This project is licensed under the MIT License. See the `LICENSE` file for more details.
Acknowledgments
----------------
- **Nostr Protocol**: Thanks to the Nostr community for the foundational technology that makes DEG Mods possible.
- **Contributors**: A shout-out to everyone who has contributed to making DEG Mods a reality.

View File

@ -3,23 +3,33 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="DEG Mods - Liberating Game Mods" />
<meta
name="description"
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" />
<!-- 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 property="og:type" content="website" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css"
<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."
/>
<!-- Links and Stylesheets -->
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
<link
@ -28,25 +38,12 @@
sizes="935x934"
href="/assets/img/Logo%20with%20circle.png"
/>
<link
rel="icon"
type="image/png"
sizes="935x934"
href="/assets/img/Logo%20with%20circle.png"
/>
<link rel="icon" type="image/png" sizes="935x934" href="/index.html" />
<link
rel="icon"
type="image/png"
sizes="935x934"
href="/assets/img/Logo%20with%20circle.png"
/>
<title>DEG Mods - Liberating Game Mods</title>
</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,12 @@
{
"names": {
"degmods": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
"degmodsreposter": "7382a4cc21742ac3e3581f1c653a41f912e985e6a941439377803b866042e53f",
"freakoverse": "3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40",
"nostrdev": "27487c9600b16b24a1bfb0519cfe4a5d1ad84959e3cce5d6d7a99d48660a1f78",
"Merlin": "76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698",
"makano": "fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10",
"reya": "126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede"
}
}

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

@ -203168,4 +203168,5 @@ Beat Saber,,
Quest Master,,
Descensus,,
Space Odyssey,,
District Panic Playtest,,
District Panic Playtest,,
Fire Emblem Engage,,
Can't render this file because it is too large.

View File

@ -12,7 +12,7 @@
<g>
<path class="cls-1" d="M379.5,237.42V3.38h75.39c20.68,0,39.27,4.69,55.78,14.06,16.5,9.38,29.39,22.61,38.66,39.7,9.27,17.09,13.96,36.25,14.06,57.47v10.77c0,21.43-4.53,40.64-13.58,57.63-9.06,16.99-21.81,30.27-38.26,39.86-16.45,9.59-34.8,14.44-55.05,14.55h-77Zm56.42-190.48V194.02h19.61c16.18,0,28.61-5.76,37.29-17.28,8.68-11.52,13.02-28.64,13.02-51.36v-10.13c0-22.61-4.34-39.65-13.02-51.12-8.68-11.47-21.33-17.2-37.94-17.2h-18.97Z"/>
<path class="cls-1" d="M737.47,138.08h-88.73v55.94h104.8v43.4h-161.22V3.38h161.55V46.94h-105.12v49.35h88.73v41.79Z"/>
<path class="cls-1" d="M968.14,208.48c-8.68,9.64-21.38,17.42-38.1,23.31-16.72,5.89-35.04,8.84-54.97,8.84-30.65,0-55.13-9.38-73.46-28.13-18.32-18.75-28.13-44.85-29.42-78.28l-.16-20.25c0-23.04,4.07-43.16,12.22-60.36,8.14-17.2,19.8-30.43,34.96-39.7C834.37,4.64,851.92,0,871.85,0,901,0,923.64,6.67,939.77,20.01c16.13,13.34,25.53,33.25,28.21,59.72h-54.33c-1.93-13.07-6.11-22.4-12.54-27.97-6.43-5.57-15.54-8.36-27.33-8.36-14.14,0-25.08,6-32.79,18-7.72,12-11.63,29.15-11.73,51.44v14.14c0,23.36,3.99,40.91,11.98,52.64,7.98,11.73,20.55,17.6,37.69,17.6,14.68,0,25.61-3.27,32.79-9.81v-36.33h-39.22v-38.74h95.64v96.12Z"/>
<path class="cls-1" d="M968.14,208.48c-8.68,9.64-21.38,17.42-38.1,23.31-16.72,5.89-35.04,8.84-54.97,8.84-30.65,0-55.13-9.38-73.46-28.13-18.32-18.75-28.13-44.85-29.42-78.28l-.16-20.25c0-23.04,4.07-43.16,12.22-60.36,8.14-17.2,19.8-30.43,34.96-39.7,15.16-9.27,32.71-13.9,52.64-13.9,29.15,0,51.78,6.67,67.91,20.01,16.13,13.34,25.53,33.25,28.21,59.72h-54.33c-1.93-13.07-6.11-22.4-12.54-27.97-6.43-5.57-15.54-8.36-27.33-8.36-14.14,0-25.08,6-32.79,18-7.72,12-11.63,29.15-11.73,51.44v14.14c0,23.36,3.99,40.91,11.98,52.64,7.98,11.73,20.55,17.6,37.69,17.6,14.68,0,25.61-3.27,32.79-9.81v-36.33h-39.22v-38.74h95.64v96.12Z"/>
</g>
<g>
<path class="cls-1" d="M427.66,266.86l33.95,105.82,33.84-105.82h48.26v152.1h-36.77v-35.52l3.55-72.71-36.77,108.22h-24.23l-36.88-108.33,3.55,72.81v35.52h-36.67v-152.1h48.16Z"/>
@ -22,11 +22,9 @@
</g>
</g>
<g>
<path class="cls-1" d="M228.02,300.66c9.1,0,16.48-7.38,16.48-16.48s-7.38-16.48-16.48-16.48-16.48,7.38-16.48,16.48,7.38,16.48,16.48,16.48Z"/>
<path class="cls-1" d="M228.02,322.68c21.26,0,38.5-17.24,38.5-38.5s-17.24-38.5-38.5-38.5-38.5,17.24-38.5,38.5,17.24,38.5,38.5,38.5Zm0-65.72c14.92,0,27.01,12.09,27.01,27.01s-12.09,27.01-27.01,27.01-27.01-12.09-27.01-27.01,12.09-27.01,27.01-27.01Z"/>
<path class="cls-1" d="M78.57,322.68c21.26,0,38.5-17.24,38.5-38.5s-17.24-38.5-38.5-38.5-38.5,17.24-38.5,38.5,17.24,38.5,38.5,38.5Zm0-65.72c14.92,0,27.01,12.09,27.01,27.01s-12.09,27.01-27.01,27.01-27.01-12.09-27.01-27.01,12.09-27.01,27.01-27.01Z"/>
<circle class="cls-1" cx="78.57" cy="284.18" r="16.48" transform="translate(-19.95 6.28) rotate(-4.06)"/>
<path class="cls-1" d="M153.62,418.95c84.62,0,153.19-68.95,153.19-153.57,.28-16.19-2.24-35.53-7.28-53.71,0,0,0,0,0,0,0,0,0-.02,0-.02-2.61-9.42-5.9-18.53-9.82-26.73-16.4-38.76-43.83-83.92-71.53-115.33,4.09,160.17-66.59,58.67-48.5-69.58-59.22,49.02-91.1,134.42-91.1,164.47-9.62-12.19-17.37-32.1-10.81-55.35C19.61,148.65-6.72,227.18,1.48,283.63c8.87,76.18,73.58,135.32,152.14,135.32Zm49.49-216.24c13.57-4.9,27-2.15,30,6.16,3,8.31-6.9,9.93-20.47,14.83-13.57,4.9-25.66,11.23-28.66,2.93-3-8.3,5.56-19.01,19.13-23.92Zm24.9,32.22c17.3,0,32.51,8.93,41.3,22.43,3.49-2.32,7.98-5.66,13.79-10.42-.64,17.02-2.93,28.26-5.88,35.64,.02,.53,.04,1.06,.04,1.6,0,27.2-22.05,49.25-49.25,49.25s-49.25-22.05-49.25-49.25,22.05-49.25,49.25-49.25Zm-71.52,119.09c4.91-.54,10.07-1.22,15.61-2.08l15.43,13.19,10.39-17.94c6.17-1.28,12.8-2.75,19.99-4.43-3.47,34.68-25.65,53.03-56.47,56.41-30.82,3.38-62.2-12.83-74.16-39.77,23.61-1.03,53.06-3.61,69.21-5.38ZM73.5,209.05c3-8.3,16.43-11.06,30-6.16,13.57,4.9,22.13,15.61,19.13,23.92-3,8.3-15.09,1.97-28.66-2.93s-23.47-6.53-20.47-14.83Zm-36.22,48.3c8.78-13.49,23.99-22.42,41.29-22.42,27.2,0,49.25,22.05,49.25,49.25s-22.05,49.25-49.25,49.25-49.25-22.05-49.25-49.25c0-.56,.02-1.11,.04-1.66-2.94-7.37-5.22-18.6-5.86-35.57,5.8,4.75,10.29,8.08,13.77,10.4Z"/>
<path class="cls-1" d="M115.73,277.18c0-18.87-15.29-34.16-34.16-34.16s-34.16,15.29-34.16,34.16,15.29,34.16,34.16,34.16,34.16-15.29,34.16-34.16Zm-54.93-.19c0-11.47,9.3-20.77,20.77-20.77s20.77,9.3,20.77,20.77-9.3,20.77-20.77,20.77-20.77-9.3-20.77-20.77Z"/>
<path class="cls-1" d="M225.02,243.02c-18.86,0-34.16,15.29-34.16,34.16s15.29,34.16,34.16,34.16,34.16-15.29,34.16-34.16-15.29-34.16-34.16-34.16Zm0,54.74c-11.47,0-20.77-9.3-20.77-20.77s9.3-20.77,20.77-20.77,20.77,9.3,20.77,20.77-9.3,20.77-20.77,20.77Z"/>
<path class="cls-1" d="M299.53,211.67s0,0,0,0c0,0,0-.02,0-.02-2.61-9.42-5.9-18.53-9.82-26.73-16.4-38.76-43.83-83.92-71.53-115.33C222.26,229.75,151.58,128.25,169.67,0c-59.22,49.02-91.1,134.42-91.1,164.47-9.62-12.19-17.37-32.1-10.81-55.35C19.61,148.65-6.72,227.18,1.48,283.63c8.87,76.18,73.58,135.33,152.14,135.33,84.62,0,153.19-68.95,153.19-153.57,.28-16.19-2.24-35.53-7.28-53.71Zm-96.3-17.98c12.94-3.65,25.22,4.55,26.37,8.64s-8.17,11.2-21.11,14.85-24.09,4.29-25.76-1.62,7.56-18.22,20.5-21.88Zm-127.61,8.64c1.15-4.09,13.43-12.29,26.37-8.64,12.94,3.66,22.17,15.97,20.5,21.88-1.67,5.91-12.82,5.27-25.76,1.62s-22.26-10.77-21.11-14.85Zm-43.29,74.85c0-.56,.02-1.11,.04-1.66-2.94-7.37-5.22-18.6-5.86-35.57,5.8,4.75,10.29,8.08,13.77,10.4,8.78-13.49,23.99-22.42,41.29-22.42,27.2,0,49.25,22.05,49.25,49.25s-22.05,49.25-49.25,49.25-49.25-22.05-49.25-49.25Zm130.22,125.42c-34.61,3.8-69.85-14.41-83.27-44.65,26.51-1.15,59.58-4.05,77.71-6.04,5.51-.6,11.3-1.37,17.53-2.34l17.33,14.81,11.66-20.15c6.93-1.44,14.38-3.09,22.45-4.98-3.9,38.94-28.8,59.55-63.41,63.34Zm111.73-125.42c0,27.2-22.05,49.25-49.25,49.25s-49.25-22.05-49.25-49.25,22.05-49.25,49.25-49.25c17.3,0,32.51,8.93,41.3,22.43,3.49-2.32,7.98-5.66,13.79-10.42-.64,17.02-2.93,28.26-5.88,35.64,.02,.53,.04,1.06,.04,1.6Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<path class="cls-1" d="M346.26,1070.9c-5.53,0-11.05-.24-16.55-.71-33.85-2.91-65.97-14.97-92.87-34.87-35.12-25.99-61.25-64.4-77.65-114.16-17.6-53.39-23.73-119.98-18.23-197.91,1.51-21.43,20.11-37.59,41.55-36.07,21.43,1.51,37.58,20.12,36.07,41.55-4.79,67.78,.1,124.33,14.52,168.07,11.27,34.18,28.1,59.74,50.03,75.97,34.85,25.79,77.61,23.3,106,12.39,41.25-15.87,71.89-36.44,60.28-142.57-12.19-111.45-69.11-296.1-190.35-617.42-16.35-43.34-31.68-85.14-43.17-117.72-5.03-14.26-9.14-26.25-11.89-34.67-1.47-4.5-2.55-7.97-3.3-10.63-.56-1.97-.97-3.58-1.28-5.07-3.55-16.78,3.5-28.44,8.03-33.89,8.93-10.76,22.99-15.92,36.76-13.49,7.31,1.29,20.79,5.93,28.98,22.47,.8,1.61,1.68,3.56,2.78,6.12,1.81,4.22,4.3,10.37,7.41,18.27,6.7,17.06,16.11,41.86,27.95,73.7,1.02,2.74,2.08,5.6,3.19,8.59,64.44-31.89,167.78-43.6,251.31-8.8,45.84,19.1,90.93,53.01,126.97,95.48,36.25,42.72,61.23,91.6,70.34,137.64,11.65,58.88-2.72,112.1-40.46,149.84-6.53,6.53-13.68,12.32-21.3,17.41,12.47,3.98,24.96,8.57,37.38,13.91,35.49,15.26,67.23,35.55,94.33,60.32,30.81,28.15,56.9,63.18,77.54,104.12,19.42,38.51,29.64,78.31,30.37,118.31,.7,38.28-7.71,75.44-24.31,107.45-28.81,55.55-80.46,92.61-141.7,101.67-54.5,8.07-100.82,3.51-141.61-13.93-38.31-16.38-70.85-43.81-99.49-83.86-2.54-3.56-5.04-7.19-7.49-10.9-10.31,65-45.42,105.7-109.31,130.27-22.52,8.66-46.71,13.12-70.8,13.12Zm121.68-501.85c5.5,16.67,10.85,33.08,16.15,49.32,39.13,119.89,70.03,214.59,113.07,274.78,41.66,58.26,89.87,77.39,166.41,66.07,36.76-5.44,66.6-26.93,84.01-60.52,22.75-43.86,20.33-101.77-6.46-154.9-69.22-137.27-190.86-151.43-298.18-163.92-26.34-3.07-51.75-6.02-74.99-10.83Zm-32-94.3c2.47,.39,5.26,.86,8.41,1.38,24.74,4.11,70.78,11.76,117.62,11.18,50.54-.62,87.08-10.76,105.65-29.33,19.34-19.34,25.79-46.16,19.15-79.71-13.72-69.37-80.01-146.85-150.91-176.39-66.87-27.87-153.56-13.19-194.29,10.66,21.21,58.09,50.17,138.2,88.82,246.56,1.87,5.25,3.72,10.47,5.54,15.65Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,8 +1,27 @@
import { Route, Routes } from 'react-router-dom'
import { Layout } from './layout'
import { routes } from './routes'
import { useEffect } from 'react'
import './styles/styles.css'
function App() {
useEffect(() => {
// Find the element with id 'root'
const rootElement = document.getElementById('root')
if (rootElement) {
// Add the class to the element
rootElement.classList.add('bodyMain')
}
// Cleanup function (optional): Remove the class when the component unmounts
return () => {
if (rootElement) {
rootElement.classList.remove('bodyMain')
}
}
}, [])
return (
<Routes>
<Route element={<Layout />}>

View File

@ -3,14 +3,12 @@ import navStyles from '../styles/nav.module.scss'
export const Banner = () => {
return (
<div className={navStyles.FundingCampaign}>
<a
<p
className={navStyles.FundingCampaignLink}
href='https://geyser.fund/project/degmods'
target='_blank'
>
DEG Mods is running a crowd funding campaign. Chip-in or share the link
to help bring the project to life (click me).
</a>
DEG Mods is currently in 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) => ({
@ -185,7 +169,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
let hexPubkey: string
if (userState.isAuth && userState.user?.pubkey) {
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
@ -204,6 +188,9 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
const uuid = uuidv4()
const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: currentTimeStamp,
@ -211,10 +198,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
content: formState.body,
tags: [
['d', formState.dTag || uuid],
[
'a',
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
@ -267,14 +251,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
)}`
)
const nevent = nip19.neventEncode({
id: signedEvent.id,
author: signedEvent.pubkey,
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
navigate(getModsInnerPageRoute(nevent))
navigate(getModPageRoute(naddr))
}
setIsPublishing(false)
@ -337,10 +321,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
} else {
for (let i = 0; i < formState.downloadUrls.length; i++) {
const downloadUrl = formState.downloadUrls[i]
if (
!isValidUrl(downloadUrl.url) ||
!(await isReachable(downloadUrl.url))
) {
if (!isValidUrl(downloadUrl.url)) {
if (!errors.downloadUrls)
errors.downloadUrls = Array(formState.downloadUrls.length)
@ -780,7 +761,7 @@ const GameDropdown = ({
can add it.
</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<input
ref={inputRef}
type='text'
@ -812,7 +793,7 @@ const GameDropdown = ({
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
</svg>
</button>
<div className='dropdown-menu dropdownMainMenu'>
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
<List
height={500}
width={'100%'}

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,172 +1,204 @@
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='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_Author'>
<div className='IBMSMSMSSS_Author_Top'>
<div className='IBMSMSMSSS_Author_Top_Left'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile profile={profile} />
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
{posts.map((post, index) => (
<a
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
href='profile.html'
key={'post' + index}
className='IBMSMSMSSS_ShortPostsPostLink'
href={post.link}
>
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background:
"url('/assets/img/media-cache%20(4).png') center / cover no-repeat"
}}
></div>
<div className='IBMSMSMSSS_ShortPostsPost'>
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
{post.name}
</p>
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
style={{ width: '100%', height: '100%' }}
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
</svg>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>
{author.name}
</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>
{author.handle}
</p>
</div>
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
<p>{post.content}</p>
{post.imageUrl && (
<div
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
style={{
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
}}
></div>
)}
</div>
</div>
</a>
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
<p
id='SiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_Address'
>
{author.address}
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
<div
id='copySiteOwnerAddress'
className='IBMSMSMSSS_Author_Top_IconWrapped'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
<a
className='IBMSMSMSSS_Author_Top_IconWrapped'
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
target='_blank'
rel='noopener noreferrer'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
</svg>
</a>
</div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
<div
id='OwnerFollowLogin'
className='IBMSMSMSSS_Author_Top_NostrLinks'
style={{ display: 'flex' }}
></div>
</div>
))}
</div>
<button className='btn btnMain' type='button'>
Follow
</button>
</div>
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
{posts.map((post, index) => (
<a
key={'post' + index}
className='IBMSMSMSSS_ShortPostsPostLink'
href={post.link}
>
<div className='IBMSMSMSSS_ShortPostsPost'>
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
{post.name}
</p>
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
style={{ width: '100%', height: '100%' }}
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
</svg>
</div>
</div>
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
<p>{post.content}</p>
{post.imageUrl && (
<div
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
style={{
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
}}
></div>
)}
</div>
</div>
</a>
))}
</div>
</div>
</div>
)
}
interface Author {
name: string
handle: string
address: string
bio: string
type ProfileProps = {
profile: NDKUserProfile
}
const author: Author = {
name: 'Freakoverse',
handle: 'freakoverse@degmods.com',
address: 'npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r',
bio: `I guess I'm one of those #vtubers . Having fun talking about general topics, vrchat/similar, and games. Also #indiedev #gamedev You can call me: Freak فْرِيكٌ <20><><EFBFBD>リク (still learning Nihongo). #envtuber #podcast #gaming #gamedev`
export const Profile = ({ 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 {
@ -178,47 +210,330 @@ interface Post {
const posts: Post[] = [
{
name: 'Freakoverse',
name: 'User name',
link: `feed-note.html`,
content: ` So I know HTML/CSS pretty well and I'm confident with
them.\n\n\n\nI also know UI and UX, as
well as graphic design (nowhere near pros, but I'm the
guy they call when the pro isn't around or when
something is needed quickly).\n\n\n\nI
don't know much java. Usually, I'd search for what I
want, find something close, and fiddle with it until
it works/gets the desired result ish. AI is helping
with this a lot actually.\n\n\n\nThis
helped me create my own sites and my own designs to
life, though just at a static level. I always wanted
to make dynamic sites, but the idea of doing backend
stuff is complex to me. However...\n\n\n\n"Let
me look into it again" and thought if I could make a
simple blog. Digging a bit, and watching/skimming
through tutorials, I realized that I think I can.\n\n\n\nNot
sure when I'll start/attempt this, but will journey
into learning the basics of PHP and attempting to make
a blog. I guess I'll learn the basics of PHP, and then
head into Laravel. If I manage to get the hang of it,
I'll attempt to make a complex old project I had, and
if I do manage to do it, I'll be pretty confident
=3\n\n\n\nAside from that, would be
nice to make a website, a personal blog, that shows my
long-form articles only. Hopefully by then things
would be more stable nostr-wise, cleaner, and easier
in terms of learning, so I'd be able to do it (or
collab with someone to do it / to make a template for
all to have and deploy easily).\n\n\n\n`
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
},
{
name: 'Freakoverse',
link: 'https://primal.net/e/note1j7zmj4g6grc39zq30xq2de95dfszjpwlqvsktv65h7kuzjzsytjqgx73c7',
content: `Happy to see some gamedevs port their games from Unity to Godot, after that Unity fiasco, like this one here called Road To Vostok`
name: 'User name',
link: 'feed-note.html',
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
},
{
name: 'Freakoverse',
name: 'User name',
link: `feed-note.html`,
content: `This is good.`,
imageUrl: '/assets/img/media-cache%20(1).jpg'
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`,
imageUrl: '/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} />}
</>
)
}

462
src/components/Zap.tsx Normal file
View File

@ -0,0 +1,462 @@
import { QRCodeSVG } from 'qrcode.react'
import React, {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useMemo,
useState
} from 'react'
import Countdown, { CountdownRenderProps } from 'react-countdown'
import { toast } from 'react-toastify'
import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import '../styles/popup.css'
import { PaymentRequest } from '../types'
import {
copyTextToClipboard,
formatNumber,
getZapAmount,
unformatNumber
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
type PresetAmountProps = {
label: string
value: number
setAmount: Dispatch<SetStateAction<number>>
}
export const PresetAmount = React.memo(
({ label, value, setAmount }: PresetAmountProps) => {
return (
<button
className='btn btnMain pUMCB_ZapsInsideAmountOptionsBtn'
type='button'
onClick={() => setAmount(value)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
</svg>
{label}
</button>
)
}
)
type ZapPresetsProps = {
setAmount: Dispatch<SetStateAction<number>>
}
export const ZapPresets = React.memo(({ setAmount }: ZapPresetsProps) => {
return (
<>
<PresetAmount label='1K' value={1000} setAmount={setAmount} />
<PresetAmount label='5K' value={5000} setAmount={setAmount} />
<PresetAmount label='10K' value={10000} setAmount={setAmount} />
<PresetAmount label='25K' value={25000} setAmount={setAmount} />
</>
)
})
type ZapButtonsProps = {
disabled: boolean
handleGenerateQRCode: () => void
handleSend: () => void
}
export const ZapButtons = ({
disabled,
handleGenerateQRCode,
handleSend
}: ZapButtonsProps) => {
return (
<div className='pUMCB_ZapsInsideBtns'>
<button
className='btn btnMain pUMCB_ZapsInsideElementBtn'
type='button'
onClick={handleGenerateQRCode}
disabled={disabled}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</button>
<button
className='btn btnMain pUMCB_ZapsInsideElementBtn'
type='button'
onClick={handleSend}
disabled={disabled}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
</svg>
Send
</button>
</div>
)
}
type ZapQRProps = {
paymentRequest: PaymentRequest
handleClose: () => void
handleQRExpiry: () => void
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
}
export const ZapQR = React.memo(
({
paymentRequest,
handleClose,
handleQRExpiry,
setTotalZapAmount,
setHasZapped
}: ZapQRProps) => {
useDidMount(() => {
ZapController.getInstance()
.pollZapReceipt(paymentRequest)
.then((zapReceipt) => {
toast.success(`Successfully sent sats!`)
if (setTotalZapAmount) {
const amount = getZapAmount(zapReceipt)
setTotalZapAmount((prev) => prev + amount)
if (setHasZapped) setHasZapped(true)
}
})
.catch((err) => {
toast.error(err.message || err)
})
.finally(() => {
handleClose()
})
})
const onQrCodeClicked = async () => {
if (!paymentRequest) return
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
zapController.sendPayment(paymentRequest.pr)
} else {
console.warn('Webln provider not present')
const href = `lightning:${paymentRequest.pr}`
const a = document.createElement('a')
a.href = href
a.click()
}
}
return (
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={paymentRequest.pr}
height={235}
width={235}
/>
<label
className='popUpMainCardBottomLnurl'
onClick={() => {
copyTextToClipboard(paymentRequest.pr).then((isCopied) => {
if (isCopied) toast.success('Lnurl copied to clipboard!')
})
}}
>
{paymentRequest.pr}
</label>
<Timer onTimerExpired={handleQRExpiry} />
</div>
)
}
)
const MAX_POLLING_TIME = 2 * 60 * 1000 // 2 minutes in milliseconds
const renderer = ({ minutes, seconds }: CountdownRenderProps) => (
<span>
{minutes}:{seconds}
</span>
)
type TimerProps = {
onTimerExpired: () => void
}
const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
const expiryTime = useMemo(() => {
return Date.now() + MAX_POLLING_TIME
}, [])
return (
<div>
<i className='fas fa-clock'></i>
<Countdown
date={expiryTime}
renderer={renderer}
onComplete={onTimerExpired}
/>
</div>
)
})
type ZapPopUpProps = {
title: string
labelDescriptionMain?: ReactNode
receiver: string
eventId?: string
aTag?: string
notCloseAfterZap?: boolean
lastNode?: ReactNode
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
handleClose: () => void
}
export const ZapPopUp = ({
title,
labelDescriptionMain,
receiver,
eventId,
aTag,
lastNode,
notCloseAfterZap,
setTotalZapAmount,
setHasZapped,
handleClose
}: ZapPopUpProps) => {
const [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,13 @@ 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',
Write = 'writeRelayUrls',
Both = 'bothRelayUrls'
}
/**
* Singleton class to manage metadata operations using NDK.
@ -13,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({
@ -23,9 +30,18 @@ export class MetadataController {
import.meta.env.VITE_APP_RELAY
]
})
this.ndk.connect()
this.ndk
.connect()
.then(() => {
console.log('NDK connected')
})
.catch((err) => {
console.log('error in ndk connection', err)
})
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB
}
private setAdminRelays = async () => {
@ -63,7 +79,7 @@ export class MetadataController {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
MetadataController.instance.setAdminRelays()
await MetadataController.instance.setAdminRelays()
}
return MetadataController.instance
}
@ -102,48 +118,133 @@ export class MetadataController {
return this.findMetadata(this.adminNpubs[0])
}
public findWriteRelays = async (hexKey: string) => {
const ndkRelayList = await getRelayListForUser(hexKey, this.ndk)
public findUserRelays = async (
hexKey: string,
userRelaysType: UserRelaysType = UserRelaysType.Both
): 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.writeRelayUrls
// 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,12 +1,19 @@
import { Event, Filter, Relay } from 'nostr-tools'
import { log, LogType, normalizeWebSocketURL, timeout } from '../utils'
import { MetadataController } from './metadata'
import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools'
import {
extractZapAmount,
log,
LogType,
normalizeWebSocketURL,
timeout
} from '../utils'
import { MetadataController, UserRelaysType } from './metadata'
/**
* 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[] = []
@ -54,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.
*
@ -62,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)
@ -70,28 +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.findWriteRelays(event.pubkey)
// 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)
)
@ -145,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.
@ -155,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!')
}
@ -180,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], {
@ -228,12 +463,296 @@ 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 (
user: string,
eTag: string,
aTag?: string,
currentLoggedInUser?: string
) => {
const metadataController = await MetadataController.getInstance()
const relayUrls = await metadataController.findUserRelays(
user,
UserRelaysType.Read
)
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)
)
// 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 = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
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 = zapRequest.pubkey === currentLoggedInUser
}
}
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
return {
accumulatedZapAmount,
hasZapped
}
}
}

View File

@ -1,7 +1,5 @@
import { Invoice } from '@getalby/lightning-tools'
import axios, { AxiosInstance } from 'axios'
import { bech32 } from 'bech32'
import { Buffer } from 'buffer'
import { Filter, kinds } from 'nostr-tools'
import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln'
import {
@ -14,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.
@ -47,6 +46,7 @@ export class ZapController {
* @param senderPubkey - pubKey of of the sender.
* @param content - optional content (comment).
* @param eventId - event id, if zapping an event.
* @param aTag - value of `a` tag.
* @returns - promise that resolves into object containing zap request and payment
* request string
*/
@ -56,7 +56,8 @@ export class ZapController {
recipientPubKey: string,
senderPubkey: string,
content?: string,
eventId?: string
eventId?: string,
aTag?: string
) {
// Check if amount is greater than 0
if (amount <= 0) throw 'Amount should be > 0.'
@ -77,20 +78,14 @@ export class ZapController {
throw `Amount '${amount}' is not within minSendable and maxSendable values '${minSendable}-${maxSendable}'.`
}
// encode lnurl into bech32 using lnurl prefix
const lnurlBech32 = bech32.encode(
'lnurl',
bech32.toWords(Buffer.from(lnurl, 'utf8'))
)
// generate zap request
const zapRequest = await this.createZapRequest(
amount,
content,
lnurlBech32,
recipientPubKey,
senderPubkey,
eventId
eventId,
aTag
)
if (!window.nostr?.signEvent) {
@ -119,7 +114,7 @@ export class ZapController {
// send zap request as GET request to callback url received from the lnurl pay endpoint
const { data } = await this.httpClient.get(
`${callback}?amount=${amount}&nostr=${encodedEvent}&lnurl=${lnurlBech32}`
`${callback}?amount=${amount}&nostr=${encodedEvent}`
)
// data object of the response should contain payment request
@ -153,7 +148,7 @@ export class ZapController {
const cleanup = () => {
clearTimeout(timeout)
sub.close()
subscriptions.forEach((subscription) => subscription.close())
}
// Polling timeout
@ -166,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 = {
@ -180,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)
}
}
}
}
})
)
})
}
@ -268,31 +263,40 @@ export class ZapController {
* Constructs zap request object.
* @param amount - request amount (sats).
* @param content - comment.
* @param lnurl - lnurl pay endpoint.
* @param recipientPubKey - pubKey of the recipient.
* @param senderPubkey - pubKey of of the sender.
* @param eventId - event id, if zapping an event.
* @param aTag - value of `a` tag.
* @returns zap request
*/
private async createZapRequest(
amount: number,
content = '',
lnurl: string,
recipientPubKey: string,
senderPubkey: string,
eventId?: string
eventId?: string,
aTag?: string
): Promise<ZapRequest> {
const recipientHexKey = npubToHex(recipientPubKey)
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}`],
['lnurl', lnurl],
['p', recipientHexKey]
],
pubkey: senderPubkey,
@ -302,6 +306,8 @@ export class ZapController {
// add event id to the tags, if zapping an event.
if (eventId) zapRequest.tags.push(['e', eventId])
if (aTag) zapRequest.tags.push(['a', aTag])
return zapRequest
}

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

@ -43,10 +43,12 @@ a:hover {
color: #535bf2;
}
/*
h1 {
font-size: 3.2em;
line-height: 1.1;
}
*/
button {
border-radius: 8px;

View File

@ -2,34 +2,18 @@ import {
init as initNostrLogin,
launch as launchNostrLoginDialog
} from 'nostr-login'
import React, {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState
} from 'react'
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Banner } from '../components/Banner'
import { MetadataController, ZapController } from '../controllers'
import { ZapPopUp } from '../components/Zap'
import { MetadataController } from '../controllers'
import { useAppDispatch, useAppSelector, useDidMount } from '../hooks'
import { appRoutes } from '../routes'
import { setIsAuth, setUser } from '../store/reducers/user'
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 {
copyTextToClipboard,
formatNumber,
npubToHex,
unformatNumber
} from '../utils'
import { toast } from 'react-toastify'
import { PaymentRequest } from '../types'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { QRCodeSVG } from 'qrcode.react'
import Countdown, { CountdownRenderProps } from 'react-countdown'
import { npubToHex } from '../utils'
export const Header = () => {
const dispatch = useAppDispatch()
@ -38,14 +22,19 @@ export const Header = () => {
useEffect(() => {
initNostrLogin({
darkMode: true,
localSignup: true,
noBanner: true,
methods: ['extension'],
onAuth: (npub, opts) => {
if (opts.type === 'logout') {
dispatch(setIsAuth(false))
dispatch(setAuth(null))
dispatch(setUser(null))
} else {
dispatch(setIsAuth(true))
dispatch(
setAuth({
method: opts.method,
localNsec: opts.localNsec
})
)
dispatch(
setUser({
npub,
@ -141,20 +130,23 @@ export const Header = () => {
</svg>
Settings
</Link>
{!userState.isAuth && (
<a
id='loginNav'
className={navStyles.NMTI_SecInside_Link}
onClick={handleLogin}
>
<img
className={navStyles.NMTI_SecInside_LinkImg}
src='/assets/img/DEG%20Mods%20Default%20PP.png'
/>
Login
</a>
{!userState.auth && (
<>
<RegisterButtonWithDialog />
<a
id='loginNav'
className={navStyles.NMTI_SecInside_Link}
onClick={handleLogin}
>
<img
className={navStyles.NMTI_SecInside_LinkImg}
src='/assets/img/DEG%20Mods%20Default%20PP.png'
/>
Login
</a>
</>
)}
{userState.isAuth && userState.user && (
{userState.auth && userState.user && (
<div className={navStyles.NMTI_SecInside_Link}>
{userState.user.image && (
<img
@ -178,30 +170,79 @@ export const Header = () => {
<div className={navStyles.NavMainBottom}>
<div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainBottomInside}>
<Link
to={appRoutes.games}
className={navStyles.NavMainBottomInsideLink}
<div
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}
></div>
<div className={navStyles.NavMainBottomInsideLinks}>
<Link
to={appRoutes.games}
className={navStyles.NavMainBottomInsideLink}
>
Games
</Link>
<Link
to={appRoutes.mods}
className={navStyles.NavMainBottomInsideLink}
>
Mods
</Link>
<Link
to={appRoutes.about}
className={navStyles.NavMainBottomInsideLink}
>
About
</Link>
<Link
to={appRoutes.blog}
className={navStyles.NavMainBottomInsideLink}
>
Blog
</Link>
</div>
<div
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}
>
Games
</Link>
<Link
to={appRoutes.mods}
className={navStyles.NavMainBottomInsideLink}
>
Mods
</Link>
<Link
to={appRoutes.about}
className={navStyles.NavMainBottomInsideLink}
>
About
</Link>
<Link
to={appRoutes.blog}
className={navStyles.NavMainBottomInsideLink}
>
Blog
</Link>
<a
className={navStyles.NavMainBottomInsideOtherLink}
href='https://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'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
</svg>
</a>
<a
className={navStyles.NavMainBottomInsideOtherLink}
href='https://www.youtube.com/@DEGModsDotCom'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
</svg>
</a>
</div>
</div>
</div>
</div>
@ -210,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.isAuth && 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 (
<>
@ -346,20 +276,67 @@ const TipButtonWithDialog = React.memo(() => {
</svg>
Tip
</a>
{isOpen && (
<div
id='PopUpMainZap'
className='popUpMain'
style={{ display: 'flex' }}
>
{isOpen && adminNpub && (
<ZapPopUp
title='Tip/Zap DEG Mods'
receiver={adminNpub}
handleClose={() => setIsOpen(false)}
labelDescriptionMain={
<p className='labelDescriptionMain' style={{ textAlign: 'center' }}>
If you don't want the development and maintenance of DEG Mods to
stop, then a tip helps!
</p>
}
lastNode={
<div className='BTCAddressPopZap'>
<p>
DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '}
<a
href='https://youtu.be/payDPlHzp58?t=215'
className='linkMain'
target='_blank'
>
Learn more
</a>
):
<br />
<span className='BTCAddressPopZapTextSpan'>
sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd
</span>
</p>
</div>
}
/>
)}
</>
)
})
const RegisterButtonWithDialog = () => {
const [showPopUp, setShowPopUp] = useState(false)
return (
<>
<a
id='registerNav'
className={navStyles.NMTI_SecInside_Link}
onClick={() => setShowPopUp(true)}
>
Register
</a>
{showPopUp && (
<div id='PopUpMainRegister' className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Tip/Zap DEG Mods</h3>
<h3>Create an account via</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<div
className='popUpMainCardTopClose'
onClick={() => setShowPopUp(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
@ -368,108 +345,85 @@ const TipButtonWithDialog = React.memo(() => {
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 d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='pUMCB_ZapsInsideAmount'>
<div className='inputLabelWrapperMain'>
<p
className='labelDescriptionMain'
style={{ textAlign: 'center' }}
>
If you want the development and maintenance of DEG
Mods to stop, then a tip helps continue it.
</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'>
<PresetAmount
label='1K'
value={1000}
setAmount={setAmount}
/>
<PresetAmount
label='5K'
value={5000}
setAmount={setAmount}
/>
<PresetAmount
label='10K'
value={10000}
setAmount={setAmount}
/>
<PresetAmount
label='25K'
value={25000}
setAmount={setAmount}
/>
</div>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Message (optional)
Browser Extensions (Windows)
</label>
<input
type='text'
className='inputMain'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<p className='labelDescriptionMain'>
Once you create your "account" on any of these, come
back and click login, then sign-in with extension.
</p>
</div>
<div className='pUMCB_ZapsInsideBtns'>
<button
className='btn btnMain pUMCB_ZapsInsideElementBtn'
type='button'
onClick={handleGenerateQRCode}
disabled={!amount}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</button>
<button
className='btn btnMain pUMCB_ZapsInsideElementBtn'
type='button'
onClick={handleSend}
disabled={!amount}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
</svg>
Send
</button>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://chromewebstore.google.com/detail/nostr-connect/ampjiinddmggbhpebhaegmjkbbeofoaj'
target='_blank'
>
Nostr Connect
</a>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://keys.band/'
target='_blank'
>
Keys.Band
</a>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp'
target='_blank'
>
nos2x
</a>
</div>
<p
className='labelDescriptionMain'
style={{
padding: '10px',
borderRadius: '10px',
background: 'rgba(0,0,0,0.1)',
border: 'solid 1px rgba(255,255,255,0.1)',
margin: '10px 0 0 0'
}}
>
Q:&nbsp;Why can't I create an account normally?
<br />
A: DEG Mods can't ban you or delete your content (we can
only hide you), and the consequence of that is this kind of
registration/login system.
</p>
<div className='dividerPopup'>
<div className='dividerPopupLine'></div>
<p>or</p>
<div className='dividerPopupLine'></div>
</div>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Browser Extensions (iOS)
</label>
<p className='labelDescriptionMain'>
Once you create your "account" on any of these, come
back and click login, then sign-in with extension.
</p>
</div>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://apps.apple.com/us/app/nostore/id1666553677'
target='_blank'
>
Nostore Browser Extension
</a>
</div>
</div>
</div>
@ -477,129 +431,6 @@ const TipButtonWithDialog = React.memo(() => {
</div>
</div>
)}
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
})
type PresetAmountProps = {
label: string
value: number
setAmount: Dispatch<SetStateAction<number>>
}
const PresetAmount = React.memo(
({ label, value, setAmount }: PresetAmountProps) => {
return (
<button
className='btn btnMain pUMCB_ZapsInsideAmountOptionsBtn'
type='button'
onClick={() => setAmount(value)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z' />
</svg>
{label}
</button>
)
}
)
type ZapQRProps = {
paymentRequest: PaymentRequest
handleClose: () => void
handleQRExpiry: () => void
}
const ZapQR = React.memo(
({ paymentRequest, handleClose, handleQRExpiry }: ZapQRProps) => {
useDidMount(() => {
ZapController.getInstance()
.pollZapReceipt(paymentRequest)
.then(() => {
toast.success(`Successfully sent sats!`)
})
.catch((err) => {
toast.error(err.message || err)
})
.finally(() => {
handleClose()
})
})
const onQrCodeClicked = async () => {
if (!paymentRequest) return
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
zapController.sendPayment(paymentRequest.pr)
} else {
console.warn('Webln provider not present')
const href = `lightning:${paymentRequest.pr}`
const a = document.createElement('a')
a.href = href
a.click()
}
}
return (
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={paymentRequest.pr}
height={235}
width={235}
/>
<label
className='popUpMainCardBottomLnurl'
onClick={() => {
copyTextToClipboard(paymentRequest.pr).then((isCopied) => {
if (isCopied) toast.success('Lnurl copied to clipboard!')
})
}}
>
{paymentRequest.pr}
</label>
<Timer onTimerExpired={handleQRExpiry} />
</div>
)
}
)
const MAX_POLLING_TIME = 2 * 60 * 1000 // 2 minutes in milliseconds
const renderer = ({ minutes, seconds }: CountdownRenderProps) => (
<span>
{minutes}:{seconds}
</span>
)
type TimerProps = {
onTimerExpired: () => void
}
const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
const expiryTime = useMemo(() => {
return Date.now() + MAX_POLLING_TIME
}, [])
return (
<div>
<i className='fas fa-clock'></i>
<Countdown
date={expiryTime}
renderer={renderer}
onComplete={onTimerExpired}
/>
</div>
)
})

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>
)
}

View File

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

View File

@ -10,55 +10,67 @@ export type FAQItem = {
const FAQ_ITEMS: FAQItem[] = [
{
question: "You don't host mods files?",
answer: "Nope. And that's for the better."
question: "You don't host mod files?",
answer: `We don't handle that directly, but you, as the creator, will.`
},
{
question: 'How do you assure security on the files?',
answer:
"We don't, but you, as the user, do. You know how sometimes you go to some forums or social sites and someone shares a download link, you'd see someone asking 'is this link safe?' and people would reply with yes/no, etc. People will be doing that same process here, with a bit of help by having a simple reaction system for each link so you'd get a quicker idea on the safety of these links that's being provided by mod creators."
question: 'How do you assure security of game mod files that someone downloads?',
answer: `We don't assure security directly. However, we will provide a reaction
system to help users gauge the safety of download links, and mod creators
are encouraged to include scan links.`
},
{
question: "Why are you quoting 'account'?",
answer:
"Because technically you aren't creating an 'account', you're generating/creating/obtaining an address/key-pair. Check the next question/answer to get more details."
answer: `We use 'account' in quotes because technically you're generating a
key pair, not creating a traditional account. The next FAQ explains more.`
},
{
question: "You 'can't' remove mods or ban accounts? How does that work?",
answer:
"I'll try my best to simplify the technicalities of this answer... Because of the nature of Nostr, the 'account' creation process involves the generation/obtaining two cryptographic key pairs, one private (think of that as your password that you cannot change), and one public (think of that as your username that you cannot change). These keypairs are coming from the Nostr protocol itself, and nobody controls Nostr, it's just there. Considering that, we can't 'ban' anyone directly. We might have a mute-list with public address that won't show their posts/submissions on this site, but they are still there and accessible by anyone. It's the same with someone's posts, we can't touch those as well. Gist: If someone put a gun to your / the team's head, will you censor or ban anyone? No, because we can't."
answer: `I'll try my best to simplify the technicalities of this answer... Because of the nature of Nostr,
the 'account' creation process involves the generation/obtaining two cryptographic key pairs,
one private (think of that as your password that you cannot change), and one public (think of that
as your username that you cannot change). These key pairs are coming from the Nostr protocol itself,
and nobody controls Nostr, it's just there. Considering that, we can't 'ban' anyone directly. We might
have a mute-list with public addresses that won't show their posts/submissions on this site, but they
are still there and accessible by anyone. It's the same with someone's posts, we can't touch those as well.
Gist: If someone put a gun to your / the team's head, will you censor or ban anyone? No, because we can't.`
},
{
question:
"You can't do anything about any mod or person? Nothing at all? What about the illegal stuff?",
answer:
"Directly removing the content can't be done, and directly 'banning' someone also can't be done. At most, pages/posts and people can be filtered out / hidden from the website, but people can still see the content with a quick copy/paste."
question: "You can't do anything about any mod or person? Nothing at all? What about the illegal stuff?",
answer: `Direct removal or banning is not possible. We can only filter or
hide content on the site, but it remains accessible on here and elsewhere.`
},
{
question:
'Why did you have to add Bitcoin? Why not traditional payment methods like Visa, PayPal, etc?',
answer:
"For various reasons. With traditional payment methods, not everyone has access to them, they can pressure/threaten us or mod creators or even gamers to censor or ban or not use this site by holding our funds or take them away, they can prevent you from tipping on this site or specific mod creators, and there's no privacy, among other reasons. With Bitcoin, anyone has access to it, nobody controls it so you can't be threatened with/by it, you can actually own it so you control it, and with it you're Pseudonymous."
question: 'Why did you have to add Bitcoin? Why not traditional payment methods like Visa, PayPal, etc?',
answer: `For various reasons. With traditional payment methods, not everyone has access to them, they
can pressure or threaten us, mod creators, or even gamers to censor or ban, or restrict usage of this site
by holding our funds or stealing them. They can prevent you from tipping on this site or specific mod creators,
and there's no privacy. These are just a few reasons why we aren't using traditional payment methods.
With Bitcoin, anyone has access to it, nobody controls it so you can't be threatened with/by it,
you can actually own and properly control it, and it provides pseudonymity.`
},
{
question: 'Is this an open-source project?',
answer: "Yes. Here's the repo."
answer: `Yes, DEG Mods is open-source. You can access the code repository
[here](https://github.com/your-repo).`
},
{
question: "Who's developing / maintaining DEG Mods?",
answer:
'Considering this is an open-source project, anyone can contribute to its development and maintenance. With that said, the initial idea-tor, designer, and frontend developer is Freakoverse, the initial backend developer and Nostr implementor is NAME.'
answer: `Considering this is an open-source project, anyone can contribute to its development and maintenance.
With that said, the initial idea-tor, designer, and frontend developer is [Freakoverse](https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r), and the co-developer
is [Nostr Dev](https://nostrdev.com/).`
},
{
question: "Who's that character above with the orange hair?",
answer: "That's Vivian James. A gamer that just wants to game in peace."
answer: `That's Vivian James, a fictional gamer character.`
},
{
question: "Who's that character above with the purple hair?",
answer:
"That's Moda-chan. DEG Mods' mascot. She's a master game mod creator! (Yes, she was AI generated, as such her design is temporary and will be replaced with a design created by an artist (or artists) when that time comes.)"
answer: `That's Moda-chan. DEG Mods' mascot. She's a master game mod creator! (Yes, she was AI-generated,
as such her design is temporary and will be replaced with a design created by an artist (or artists)
when that time comes.)`
}
]
];
export const AboutPage = () => {
return (
@ -108,7 +120,7 @@ export const AboutPage = () => {
imposing their ideals. DEG Mods aims to change that
narrative by being developed on Nostr, a revolutionary new
communications protocol.{' '}
<a className='linkMain' href='#'>
<a className='linkMain' href='https://nostr.com/' target="_blank">
Learn more about Nostr here.
</a>
<br />
@ -159,7 +171,9 @@ export const AboutPage = () => {
on this platform/site. Pretend its not even there. We're not
even making any money out of this project/site, in-fact,
we're running at a loss (unless direct donations/tips covers
it). This is just a passion project to help free (liberate)
it, and/or we managed to add reasonable monetization systems
to help cover further development and maintenance costs).
This is just a passion project to help free (liberate)
game mods and their creators, and this part potentially
helps them financially, even those in other countries where
"normal" methods of money payment/transfer are not an

View File

@ -12,7 +12,7 @@ export const BlogsPage = () => {
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs</h2>
<h2 className='IBMSMTitleMainHeading'>Blogs (WIP)</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
@ -92,9 +92,9 @@ export const BlogsPage = () => {
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
<BlogCard backgroundLink='https://image.nostr.build/d6af39fb1d47feaf09831ddf9d447ccc435ba10fcbb9b6d6e800390f6bbac851.png' />
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
<BlogCard backgroundLink='https://pbs.twimg.com/media/GDrRJOOXYAAeysT.jpg:large' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />

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'>
@ -16,8 +58,18 @@ export const GamesPage = () => {
<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,51 +87,20 @@ export const GamesPage = () => {
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
<GameCard backgroundLink='https://m.media-amazon.com/images/M/MV5BZTRiNTgxMjQtNDE3OS00YTg4LTg3NTItY2EyNGUzYjAzZGZmXkEyXkFqcGdeQXVyMTI0MzI0MzE4._V1_FMjpg_UX1000_.jpg' />
<GameCard backgroundLink='https://upload.wikimedia.org/wikipedia/en/0/0c/Witcher_3_cover_art.jpg' />
<GameCard backgroundLink='https://cdn2.steamgriddb.com/file/sgdb-cdn/grid/9153bb77795515274c2be61ccc59c952.jpg' />
<GameCard backgroundLink='https://static.trueachievements.com/boxart/Game_12493.jpg' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
</div>
</div>
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-left'></i>
</a>
<div className='PaginationMainInsideBoxGroup'>
<a className='PaginationMainInsideBox PMIBActive' href='#'>
<p>1</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>2</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>3</p>
</a>
<p className='PaginationMainInsideBox PMIBDots'>...</p>
<a className='PaginationMainInsideBox' href='#'>
<p>8</p>
</a>
</div>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-right'></i>
</a>
</div>
{currentGames.map((game) => (
<GameCard
key={game['Game Name']}
title={game['Game Name']}
imageUrl={game['Boxart image']}
/>
))}
</div>
</div>
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
</div>
</div>
</div>

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(https://www.ggrecon.com/media/fd1bxcwr/tifa-s-cowboy-costume-change.png) center / cover no-repeat'
}}
></div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>Game Mod Title</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(https://www.kakuchopurei.com/wp-content/uploads/2022/08/Marvels-Spider-Man-PC-Flag-Mod-1.jpg) center / cover no-repeat'
}}
></div>
<div className='IBMSMSCWSInfo'>
<h3 className='IBMSMSCWSInfoHeading'>Game Mod Title</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'>Game Mod Title</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>
@ -129,17 +67,19 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Cool Games</h2>
</div>
<div className='IBMSMList IBMSMListFeaturedAlt'>
<GameCard backgroundLink='https://m.media-amazon.com/images/M/MV5BZTRiNTgxMjQtNDE3OS00YTg4LTg3NTItY2EyNGUzYjAzZGZmXkEyXkFqcGdeQXVyMTI0MzI0MzE4._V1_FMjpg_UX1000_.jpg' />
<GameCard backgroundLink='https://upload.wikimedia.org/wikipedia/en/0/0c/Witcher_3_cover_art.jpg' />
<GameCard backgroundLink='https://cdn2.steamgriddb.com/file/sgdb-cdn/grid/9153bb77795515274c2be61ccc59c952.jpg' />
<GameCard backgroundLink='https://static.trueachievements.com/boxart/Game_12493.jpg' />
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
{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>
@ -150,77 +90,29 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Awesome Mods</h2>
</div>
<div className='IBMSMList IBMSMListAlt'>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true'
/>
{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'
onClick={() => navigate(appRoutes.mods)}
>
View All
</a>
</div>
</div>
<DisplayLatestMods />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Latest Mods</h2>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
</div>
<div className='IBMSMList'>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
/>
</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'>Blog Posts</h2>
</div>
<div className='IBMSMList'>
<BlogCard backgroundLink='https://image.nostr.build/d6af39fb1d47feaf09831ddf9d447ccc435ba10fcbb9b6d6e800390f6bbac851.png' />
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
<BlogCard backgroundLink='https://pbs.twimg.com/media/GDrRJOOXYAAeysT.jpg:large' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
</div>
@ -239,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>
)
}

View File

@ -1,926 +0,0 @@
import { formatDate } from 'date-fns'
import DOMPurify from 'dompurify'
import { Filter, nip19 } from 'nostr-tools'
import { useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { BlogCard } from '../components/BlogCard'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { ProfileSection } from '../components/ProfileSection'
import { RelayController } from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import '../styles/comments.css'
import '../styles/downloads.css'
import '../styles/innerPage.css'
import '../styles/post.css'
import '../styles/reactions.css'
import '../styles/styles.css'
import '../styles/tabs.css'
import '../styles/tags.css'
import '../styles/write.css'
import { ModDetails } from '../types'
import {
copyTextToClipboard,
extractModData,
getFilenameFromUrl,
log,
LogType
} from '../utils'
import saveAs from 'file-saver'
export const InnerModPage = () => {
const { nevent } = useParams()
const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(true)
useDidMount(async () => {
if (nevent) {
const decoded = nip19.decode<'nevent'>(nevent as `nevent1${string}`)
const eventId = decoded.data.id
const kind = decoded.data.kind
const author = decoded.data.author
const relays = decoded.data.relays || []
const filter: Filter = {
ids: [eventId]
}
if (kind) filter.kinds = [kind]
if (author) filter.authors = [author]
RelayController.getInstance()
.fetchEvent(filter, relays)
.then((event) => {
if (event) {
const extracted = extractModData(event)
setModData(extracted)
}
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in fetching mod details from relays',
err
)
toast.error('An error occurred in fetching mod details from relays')
})
.finally(() => {
setIsFetching(false)
})
}
})
const oldDownloadListRef = useRef<HTMLDivElement>(null)
const handleViewOldLinks = () => {
if (oldDownloadListRef.current) {
// Toggle styles
if (oldDownloadListRef.current.style.height === '0px') {
// Enable styles
oldDownloadListRef.current.style.padding = ''
oldDownloadListRef.current.style.height = ''
oldDownloadListRef.current.style.border = ''
} else {
// Disable styles
oldDownloadListRef.current.style.padding = '0'
oldDownloadListRef.current.style.height = '0'
oldDownloadListRef.current.style.border = 'unset'
}
}
}
if (isFetching)
return <LoadingSpinner desc='Fetching mod details from relays' />
if (!modData) return null
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<Game game={modData.game} author={modData.author} />
<Body
featuredImageUrl={modData.featuredImageUrl}
title={modData.title}
body={modData.body}
screenshotsUrls={modData.screenshotsUrls}
tags={modData.tags}
nsfw={modData.nsfw}
/>
<Interactions />
<PublishDetails
published_at={modData.published_at}
edited_at={modData.edited_at}
site={modData.rTag}
/>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSDownloadsWrapper'>
<h4 className='IBMSMSMBSSDownloadsTitle'>Mod Download</h4>
{modData.downloadUrls.length > 0 && (
<div className='IBMSMSMBSSDownloadsPrime'>
<Download url={modData.downloadUrls[0].url} />
</div>
)}
{modData.downloadUrls.length > 1 && (
<>
<div className='IBMSMSMBSSDownloadsActions'>
<button
className='btn btnMain'
id='viewOldLinks'
type='button'
onClick={handleViewOldLinks}
>
View other links
</button>
</div>
<div
ref={oldDownloadListRef}
id='oldDownloadList'
className='IBMSMSMBSSDownloads'
style={{ padding: 0, height: '0px', border: 'unset' }}
>
{modData.downloadUrls
.slice(1)
.map((download, index) => (
<Download
key={`downloadUrl-${index}`}
url={download.url}
/>
))}
</div>
</>
)}
</div>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
<div className='IBMSMList IBMSMListAlt'>
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
<BlogCard backgroundLink='https://nichegamer.com/wp-content/uploads/2023/01/onimai-01-07-2023.jpg' />
</div>
</div>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<Comments />
</div>
</div>
<ProfileSection />
</div>
</div>
</div>
</div>
)
}
type GameProps = {
game: string
author: string
}
const Game = ({ game, author }: GameProps) => {
const navigate = useNavigate()
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSMSMBSSModFor'>
<p className='IBMSMSMBSSModForPara'>
Mod for:&nbsp;
<a className='IBMSMSMBSSModForLink' href='search.html'>
{game}
</a>
</p>
<div className='dropdown dropdownMain' style={{ flexGrow: 'unset' }}>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}>
{userState.isAuth && userState.user?.pubkey === author && (
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
navigate(
window.location.pathname.replace('mods-inner', 'edit-mod')
)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then((isCopied) => {
if (isCopied) toast.success('Url copied to clipboard!')
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
href='#'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
Block Post
</a>
</div>
</div>
</div>
)
}
type BodyProps = {
featuredImageUrl: string
title: string
body: string
screenshotsUrls: string[]
tags: string[]
nsfw: boolean
}
const Body = ({
featuredImageUrl,
title,
body,
screenshotsUrls,
tags,
nsfw
}: BodyProps) => {
const postBodyRef = useRef<HTMLDivElement>(null)
const viewFullPostBtnRef = useRef<HTMLDivElement>(null)
const viewFullPost = () => {
if (postBodyRef.current && viewFullPostBtnRef.current) {
postBodyRef.current.style.maxHeight = 'unset'
postBodyRef.current.style.padding = 'unset'
viewFullPostBtnRef.current.style.display = 'none'
}
}
return (
<div className='IBMSMSMBSSPost'>
<div
className='IBMSMSMBSSPostPicture'
style={{
background: `url(${featuredImageUrl}) center / cover no-repeat`
}}
></div>
<div className='IBMSMSMBSSPostInside'>
<div className='IBMSMSMBSSPostTitle'>
<h1 className='IBMSMSMBSSPostTitleHeading'>{title}</h1>
</div>
<div
ref={postBodyRef}
className='IBMSMSMBSSPostBody'
style={{ maxHeight: '250px', padding: '0 10px' }}
>
<div
className='IBMSMSMBSSPostTitleText'
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(body)
}}
/>
<div ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
<p onClick={viewFullPost}>View</p>
</div>
</div>
<div className='IBMSMSMBSSShots'>
{screenshotsUrls.map((url, index) => (
<img
className='IBMSMSMBSSShotsImg'
src={url}
alt={`ScreenShot-${index}`}
key={`ScreenShot-${index}`}
/>
))}
</div>
<div className='IBMSMSMBSSTags'>
{nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
<p>NSFW</p>
</div>
)}
{tags.map((tag, index) => (
<a className='IBMSMSMBSSTagsTag' href='#' key={`tag-${index}`}>
{tag}
</a>
))}
</div>
</div>
</div>
)
}
const Interactions = () => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a
href='#commentsArea'
style={{ textDecoration: 'unset', color: 'unset' }}
>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>420</p>
</div>
</a>
<div
id='reactBolt'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt'
>
<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'>69k</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
id='reactUp'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp IBMSMSMBSS_D_CRUActive'
>
<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'>4.2k</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
id='reactDown'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown'
>
<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'>69</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</div>
</div>
)
}
type PublishDetailsProps = {
published_at: number
edited_at: number
site: string
}
const PublishDetails = ({
published_at,
edited_at,
site
}: PublishDetailsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/m/yyyy'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/m/yyyy')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}
type DownloadProps = {
url: string
}
const Download = ({ url }: DownloadProps) => {
const handleDownload = () => {
// Get the filename from the URL
const filename = getFilenameFromUrl(url)
saveAs(url, filename)
}
return (
<div className='IBMSMSMBSSDownloadsElement'>
<div className='IBMSMSMBSSDownloadsElementInside'>
<button
className='btn btnMain IBMSMSMBSSDownloadsElementBtn'
type='button'
onClick={handleDownload}
>
Download
</button>
</div>
<div className='IBMSMSMBSSDownloadsElementInside'>
<p>Ratings:</p>
<div className='tabsMain'>
<ul className='nav nav-tabs tabsMainTop' role='tablist'>
<li className='nav-item tabsMainTopTab' role='presentation'>
<a
className='nav-link active tabsMainTopTabLink'
role='tab'
data-bs-toggle='tab'
href='#tab-1'
>
WoT
</a>
</li>
<li className='nav-item tabsMainTopTab' role='presentation'>
<a
className='nav-link tabsMainTopTabLink'
role='tab'
data-bs-toggle='tab'
href='#tab-2'
>
All
</a>
</li>
</ul>
<div className='tab-content tabsMainBottom'>
<div
className='tab-pane active tabsMainBottomContent'
role='tabpanel'
id='tab-1'
>
<div className='IBMSMSMBSSDownloadsElementInsideReactions'>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement IBMSMSMBSSDEIReactionsElementActive'
title='Clean'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M512 165.4c0 127.9-70.05 235.3-175.3 270.1c-20.04 7.938-41.83 12.46-64.69 12.46c-64.9 0-125.2-36.51-155.7-94.47c-54.13 49.93-68.71 107-68.96 108.1C44.72 472.6 34.87 480 24.02 480c-1.844 0-3.727-.2187-5.602-.6562c-12.89-3.098-20.84-16.08-17.75-28.96c9.598-39.5 90.47-226.4 335.3-226.4C344.8 224 352 216.8 352 208S344.8 192 336 192C228.6 192 151 226.6 96.29 267.6c.1934-10.82 1.242-21.84 3.535-33.05c13.47-65.81 66.04-119 131.4-134.2c28.33-6.562 55.68-6.013 80.93-.0054c56 13.32 118.2-7.412 149.3-61.24c5.664-9.828 20.02-9.516 24.66 .8282C502.7 76.76 512 121.9 512 165.4z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>420</p>
</div>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement'
title='Broken link'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M185.7 120.3C242.5 75.82 324.7 79.73 376.1 131.1C420.1 175.1 430.9 239.6 406.7 293.5L438.6 318.4L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.1 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.732 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L489.3 358.2L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L185.7 120.3zM238.1 161.1L353.4 251.7C359.3 225.5 351.7 197.2 331.7 177.2C306.6 152.1 269.1 147 238.1 161.1V161.1zM263 380C233.1 350.1 218.7 309.8 220.9 270L406.6 416.4C357.4 431 301.9 418.9 263 380V380zM116.6 187.9L167.2 227.8L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L116.6 187.9z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>420</p>
</div>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement'
title='Has virus'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M288 43.55C288 93.44 348.3 118.4 383.6 83.15L391.8 74.98C404.3 62.48 424.5 62.48 437 74.98C449.5 87.48 449.5 107.7 437 120.2L368.7 188.6C352.7 204.3 341.4 224.8 337.6 249.8L168.3 420.1C148.4 448.6 104.5 448.6 84.63 420.1C64.73 391.5 64.73 347.6 84.63 319.1L172.4 231.3C208.7 197 233.6 154.3 233.6 104.4C233.6 54.47 173.3 29.5 138 64.73L130.1 72.9C117.6 85.4 97.41 85.4 84.94 72.9C72.44 60.4 72.44 40.17 84.94 27.67L153.2-41.52C194.8-83.6 295.2-83.6 336.7-41.52C375.5 2.213 377.4 55.78 288 43.55zM121.4 169.3L44.88 245.8C25.35 265.6 25.35 295.7 44.88 315.5L138.4 408.9C147.4 417.1 166.5 421.6 181.5 417.1L296.7 295.7C306.2 272.4 318.5 253.3 333.3 239.8L237.2 142.2C222.4 123.2 204.1 105.3 181.5 105.3C159.1 105.3 141.3 123.2 126.6 142.2L121.4 169.3zM250.7 419.8L299.4 428.2C316.8 433.6 335.5 423.6 336.8 410.8L342.8 236.8C344.1 225 336.8 213.2 325.4 211.4L146.2 160.1C134.7 158.8 120.5 174.7 121.1 185.7L127.8 359.6C128.4 370.9 143.8 382.3 156.1 376.4L250.7 419.8z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>420</p>
</div>
</div>
</div>
<div
className='tab-pane tabsMainBottomContent'
role='tabpanel'
id='tab-2'
>
<div className='IBMSMSMBSSDownloadsElementInsideReactions'>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement IBMSMSMBSSDEIReactionsElementActive'
title='Clean'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M512 165.4c0 127.9-70.05 235.3-175.3 270.1c-20.04 7.938-41.83 12.46-64.69 12.46c-64.9 0-125.2-36.51-155.7-94.47c-54.13 49.93-68.71 107-68.96 108.1C44.72 472.6 34.87 480 24.02 480c-1.844 0-3.727-.2187-5.602-.6562c-12.89-3.098-20.84-16.08-17.75-28.96c9.598-39.5 90.47-226.4 335.3-226.4C344.8 224 352 216.8 352 208S344.8 192 336 192C228.6 192 151 226.6 96.29 267.6c.1934-10.82 1.242-21.84 3.535-33.05c13.47-65.81 66.04-119 131.4-134.2c28.33-6.562 55.68-6.013 80.93-.0054c56 13.32 118.2-7.412 149.3-61.24c5.664-9.828 20.02-9.516 24.66 .8282C502.7 76.76 512 121.9 512 165.4z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>4,200</p>
</div>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement'
title='Broken link'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M185.7 120.3C242.5 75.82 324.7 79.73 376.1 131.1C420.1 175.1 430.9 239.6 406.7 293.5L438.6 318.4L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.1 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.732 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L489.3 358.2L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L185.7 120.3zM238.1 161.1L353.4 251.7C359.3 225.5 351.7 197.2 331.7 177.2C306.6 152.1 269.1 147 238.1 161.1V161.1zM263 380C233.1 350.1 218.7 309.8 220.9 270L406.6 416.4C357.4 431 301.9 418.9 263 380V380zM116.6 187.9L167.2 227.8L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L116.6 187.9z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>4,200</p>
</div>
<div
data-bs-toggle='tooltip'
data-bss-tooltip=''
className='IBMSMSMBSSDEIReactionsElement'
title='Has virus'
>
<div className='IBMSMSMBSSDEIReactionsElementIconWrapper'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSDEIReactionsElementIcon'
>
<path d='M288 43.55C288 93.44 348.3 118.4 383.6 83.15L391.8 74.98C404.3 62.48 424.5 62.48 437 74.98C449.5 87.48 449.5 107.7 437 120.2L368.7 188.6C352.7 204.3 341.4 224.8 337.6 249.8L168.3 420.1C148.4 448.6 104.5 448.6 84.63 420.1C64.73 391.5 64.73 347.6 84.63 319.1L172.4 231.3C208.7 197 233.6 154.3 233.6 104.4C233.6 54.47 173.3 29.5 138 64.73L130.1 72.9C117.6 85.4 97.41 85.4 84.94 72.9C72.44 60.4 72.44 40.17 84.94 27.67L153.2-41.52C194.8-83.6 295.2-83.6 336.7-41.52C375.5 2.213 377.4 55.78 288 43.55zM121.4 169.3L44.88 245.8C25.35 265.6 25.35 295.7 44.88 315.5L138.4 408.9C147.4 417.1 166.5 421.6 181.5 417.1L296.7 295.7C306.2 272.4 318.5 253.3 333.3 239.8L237.2 142.2C222.4 123.2 204.1 105.3 181.5 105.3C159.1 105.3 141.3 123.2 126.6 142.2L121.4 169.3zM250.7 419.8L299.4 428.2C316.8 433.6 335.5 423.6 336.8 410.8L342.8 236.8C344.1 225 336.8 213.2 325.4 211.4L146.2 160.1C134.7 158.8 120.5 174.7 121.1 185.7L127.8 359.6C128.4 370.9 143.8 382.3 156.1 376.4L250.7 419.8z' />
</svg>
</div>
<p className='IBMSMSMBSSDEIReactionsElementText'>4,200</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
const Comments = () => {
return (
<div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div id='ArticleComments-1' className='IBMSMSMBSSComments'>
<div className='IBMSMSMBSSCommentsCreation'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
id='commentBox-1'
className='IBMSMSMBSSCC_Top_Box'
></textarea>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
<a className='IBMSMSMBSSCC_BottomButton'>
Comment
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</a>
</div>
</div>
<div className='CommentsToggle'>
<button
className='btn btnMain CommentsToggleBtn CommentsToggleActive'
type='button'
>
All Comments
</button>
<button className='btn btnMain CommentsToggleBtn' type='button'>
Creator Comments
</button>
</div>
<div className='IBMSMSMBSSCommentsList'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<a
className='IBMSMSMBSSCL_CommentTopPP'
href='profile.html'
style={{
background: `url('/assets/img/media-cache%20(4).png') center / cover no-repeat`
}}
></a>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
FreakoverseFreakoverseFreakoverse
</a>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r
</a>
</div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime' href='feed-note.html'>
8:45 PM
</a>
<a className='IBMSMSMBSSCL_CADDate' href='feed-note.html'>
02/05/2024
</a>
</div>
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<p className='IBMSMSMBSSCL_CBText'>
Yo this article was insane to read!
</p>
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp IBMSMSMBSSCL_CAEUpActive'>
<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'>52</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown'>
<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'>4</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost IBMSMSMBSSCL_CAERepostActive'>
<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'>6</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEBolt IBMSMSMBSSCL_CAEBoltActive'>
<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'>500K</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>12</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</div>
<div className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

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.neventEncode({
id: mod.id,
author: 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

@ -1,8 +1,11 @@
import { logout } from 'nostr-login'
import { Link, useLocation } from 'react-router-dom'
import { toast } from 'react-toastify'
import { InputField } from '../components/Inputs'
import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector } from '../hooks'
import { appRoutes } from '../routes'
import { AuthMethod } from '../store/reducers/user'
import '../styles/feed.css'
import '../styles/innerPage.css'
import '../styles/popup.css'
@ -10,10 +13,13 @@ import '../styles/profile.css'
import '../styles/settings.css'
import '../styles/styles.css'
import '../styles/write.css'
import { useAppSelector } from '../hooks'
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'>
@ -31,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>
@ -41,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()
}
@ -51,7 +72,7 @@ const SettingTabs = () => {
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSplitMainSmallSideSec'>
<h3 className='IBMSMSMSSS_Text'>Settings</h3>
<h3 className='IBMSMSMSSS_Text'>Settings (WIP)</h3>
</div>
<div className='IBMSMSMSSS_Buttons'>
<Link
@ -114,28 +135,75 @@ 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.isAuth && (
{userState.auth &&
userState.auth.method === AuthMethod.Local &&
userState.auth.localNsec && (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Your Private Key</label>
<p className='labelDescriptionMain'>
NOTICE: Make sure you save your private key (nsec) somewhere
safe.
</p>
<div className='inputWrapperMain'>
<input
type='password'
className='inputMain inputMainWithBtn'
value={userState.auth.localNsec}
/>
<button
className='btn btnMain btnMainInsideField'
type='button'
onClick={() => {
copyTextToClipboard(
userState.auth?.localNsec as string
).then((isCopied) => {
if (isCopied) toast.success('Nsec copied to clipboard!')
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<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>
</button>
</div>
<p className='labelDescriptionMain'>
WARNING: Do not sign-out without saving your nsec somewhere
safe. Otherwise, you'll lose access to your "account".
</p>
</div>
)}
{userState.auth && (
<button className='btn btnMain' type='button' onClick={handleSignOut}>
Sign out
</button>
@ -155,7 +223,7 @@ const ProfileSettings = () => {
<div
className='IBMSMSMSSS_Author_Top_Banner'
style={{
background: `url('https://primal.b-cdn.net/media-cache?s=m&amp;a=1&amp;u=https%3A%2F%2Fm.primal.net%2FHerB.png') center / cover no-repeat`
background: `url('assets/img/DEGMods%20Placeholder%20Img.png') center / cover no-repeat`
}}
></div>
<a
@ -168,16 +236,16 @@ const ProfileSettings = () => {
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `url('/assets/img/media-cache%20(4).png') center / cover no-repeat`
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'>Freakoverse</p>
<p className='IBMSMSMSSS_Author_Top_Name'>User name</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>
freakoverse@degmods.com
nip5handle@domain.com
</p>
</div>
</div>
@ -189,7 +257,7 @@ const ProfileSettings = () => {
id='SiteOwnerAddress-1'
className='IBMSMSMSSS_Author_Top_Address'
>
npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r
npub1address
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
@ -220,11 +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>
<a
className='IBMSMSMSSS_Author_Top_IconWrapped'
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
target='_blank'
>
<div className='IBMSMSMSSS_Author_Top_IconWrapped'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
@ -233,17 +297,15 @@ const ProfileSettings = () => {
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
<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>
</a>
</div>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>
I guess I'm one of those #vtubers . Having fun talking about
general topics, vrchat/similar, and games. Also #indiedev
#gamedev You can call me: Freak فْرِيكٌ (still learning
Nihongo). #envtuber #podcast #gaming #gamedev
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'
@ -254,21 +316,44 @@ const ProfileSettings = () => {
</div>
</div>
<div className='IBMSMSMBSS_ProfileEdit'>
<InputField label='Name' placeholder='' name='name' />
<InputField label='Bio' placeholder='' name='bio' type='textarea' />
<InputField
label='Name'
placeholder=''
name='name'
value=''
onChange={() => {}}
/>
<InputField
label='Bio'
placeholder=''
name='bio'
type='textarea'
value=''
onChange={() => {}}
/>
<InputField
label='Profile picture URL'
placeholder=''
name='profilePicture'
inputMode='url'
value=''
onChange={() => {}}
/>
<InputField
label='Banner picture URL'
placeholder=''
name='bannerPicture'
inputMode='url'
value=''
onChange={() => {}}
/>
<InputField
label='Nip-05 address'
placeholder=''
name='nip05'
value=''
onChange={() => {}}
/>
<InputField label='Nip-05 address' placeholder='' name='nip05' />
</div>
<div
className='IBMSMSMBSS_ProfileActions'
@ -304,6 +389,8 @@ const RelaySettings = () => {
placeholder='wss://some-relay.com'
type='text'
name='relay'
value=''
onChange={() => {}}
/>
<button className='btn btnMain' type='button'>

View File

@ -11,34 +11,31 @@ 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()
const { nevent } = useParams()
const { naddr } = useParams()
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'
useDidMount(async () => {
if (nevent) {
const decoded = nip19.decode<'nevent'>(nevent as `nevent1${string}`)
const eventId = decoded.data.id
const kind = decoded.data.kind
const author = decoded.data.author
const relays = decoded.data.relays || []
if (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: Filter = {
ids: [eventId]
'#a': [identifier],
authors: [pubkey],
kinds: [kind]
}
if (kind) filter.kinds = [kind]
if (author) filter.authors = [author]
setIsFetching(true)
RelayController.getInstance()
.fetchEvent(filter, relays)
@ -79,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,26 +15,46 @@ export const WritePage = () => {
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Write a blog post</h2>
<h2 className='IBMSMTitleMainHeading'>
Write a blog post (WIP)
</h2>
</div>
<div className='IBMSMSMBS_Write'>
<InputField label='Title' placeholder='' name='title' />
<InputField label='Body' placeholder='' name='body' />
<InputField
label='Title'
placeholder=''
name='title'
value=''
onChange={() => {}}
/>
<InputField
label='Body'
placeholder=''
name='body'
value=''
onChange={() => {}}
/>
<InputField
label='Featured Image URL'
placeholder=''
name='imageUrl'
inputMode='url'
value=''
onChange={() => {}}
/>
<InputField
label='Summary'
placeholder=''
name='summary'
type='textarea'
value=''
onChange={() => {}}
/>
<CheckboxField
label='This mod not safe for work (NSFW)'
name='nsfw'
isChecked={false}
handleChange={() => {}}
/>
<div className='IBMSMSMBS_WriteAction'>
<button className='btn btnMain' type='button'>
@ -40,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,32 +1,47 @@
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/:nevent',
mod: '/mod/:naddr',
about: '/about',
blog: '/blog',
submitMod: '/submit-mod',
editMod: '/edit-mod/:nevent',
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(':nevent', 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 = [
{
@ -41,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,
@ -69,6 +88,10 @@ export const routes = [
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
@ -84,5 +107,9 @@ export const routes = [
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
}
]

View File

@ -1,13 +1,25 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { UserProfile } from '../../types/user'
export enum AuthMethod {
Connect = 'connect',
ReadOnly = 'readOnly',
Extension = 'extension',
Local = 'local',
OTP = 'otp'
}
export interface IUserAuth {
method: AuthMethod
localNsec?: string
}
export interface IUserState {
isAuth: boolean
auth: IUserAuth | null
user: UserProfile
}
const initialState: IUserState = {
isAuth: false,
auth: null,
user: {}
}
@ -15,8 +27,8 @@ export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setIsAuth(state, action: PayloadAction<boolean>) {
state = { ...state, isAuth: action.payload }
setAuth(state, action: PayloadAction<IUserAuth | null>) {
state = { ...state, auth: action.payload }
return state
},
setUser(state, action: PayloadAction<UserProfile>) {
@ -26,6 +38,6 @@ export const userSlice = createSlice({
}
})
export const { setIsAuth, setUser } = userSlice.actions
export const { setAuth, setUser } = userSlice.actions
export default userSlice.reducer

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 {
@ -157,6 +160,16 @@
}
.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 {
@ -23,6 +30,7 @@
flex-direction: column;
grid-gap: 15px;
flex-grow: 1;
justify-content: space-between;
}
.cMMFoot {
@ -98,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;
@ -26,6 +26,7 @@
display: flex;
flex-direction: column;
grid-gap: 25px;
position: relative;
}
.IBMSMSplitMainBigSideSec {
@ -129,3 +130,12 @@
color: rgba(255,255,255,0.75);
}
.IBMSMSplitMainSmallSideSecWrapper {
display: flex;
flex-direction: column;
grid-gap: 0px;
position: sticky;
top: 15px;
grid-gap: 25px;
}

View File

@ -33,8 +33,17 @@
}
&:hover {
color: rgb(255, 255, 255);
color: rgba(255, 255, 255, 0.75);
}
a {
color: white;
&:hover {
color: white;
text-decoration: underline;
}
}
}
}
@ -138,7 +147,7 @@
width: auto;
padding: 10px 15px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.5);
text-decoration: unset;
font-weight: bold;
transform: scale(1);
@ -149,7 +158,7 @@
&:hover {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.75);
color: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
text-decoration: unset;
@ -173,12 +182,7 @@
bottom: 0;
right: 0;
left: 0;
background: linear-gradient(
to top right,
#262626,
#292929,
#262626
);
background: #323232;
z-index: -1;
border-radius: 10px;
}
@ -199,7 +203,7 @@
height: auto;
width: auto;
border-radius: 10px;
color: rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.5);
text-decoration: unset;
font-weight: bold;
transform: scale(1);
@ -241,32 +245,80 @@
}
.NavMainBottomInside {
width: 100%;
display: flex;
flex-direction: row;
grid-gap: 10px;
justify-content: center;
align-items: center;
padding: 10px;
white-space: nowrap;
overflow-x: auto;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 10px;
justify-content: center;
align-items: center;
padding: 10px;
white-space: nowrap;
overflow-x: auto;
.NavMainBottomInsideLink {
.NavMainBottomInsideLink {
transition: ease 0.4s;
text-decoration: unset;
color: rgba(255, 255, 255, 0.5);
font-weight: bold;
padding: 5px 15px;
&:hover {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.85);
text-decoration: unset;
color: rgba(255, 255, 255, 0.25);
font-weight: bold;
padding: 5px 15px;
}
&:hover {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.75);
text-decoration: unset;
}
&.NMBILActive {
color: rgba(255, 255, 255, 0.65);
}
&.NMBILActive {
color: rgba(255, 255, 255, 0.65);
}
}
@media (max-width: 768px) {
display: flex;
justify-content: start;
}
}
.NavMainBottomInsideOther {
display: flex;
flex-direction: row;
grid-gap: 10px;
height: 100%;
justify-content: end;
.NavMainBottomInsideOtherLink {
transition: ease 0.4s;
width: 35px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 35px;
color: white;
opacity: 0.65;
border-radius: 5px;
background: rgba(255,255,255,0);
&:hover {
transition: ease 0.4s;
color: white;
opacity: 0.85;
background: rgba(255,255,255,0.1);
}
}
.NavMainBottomInsideOtherLeft {
display: flex;
justify-content: start;
@media (max-width: 768px) {
display: none;
}
}
.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,13 +53,15 @@
align-items: center;
padding: 25px;
overflow: auto;
max-height: 70vh;
}
.popUpMainCardBottomQR {
width: 100%;
max-width: 250px;
cursor: pointer;
padding: 17px 0px;
background: white;
border-radius: 5px;
}
.popUpMainCardBottomLnurl {
@ -86,14 +88,12 @@
font-size: 20px;
position: relative;
cursor: pointer;
border: solid 1px rgba(255, 255, 255, 0);
}
.popUpMainCardTopClose:hover {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.75);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
border: solid 1px rgba(255, 255, 255, 0.1);
}
.popUpMainCardTopInfo {
@ -115,7 +115,7 @@
bottom: 0;
right: 0;
left: 0;
background: linear-gradient(to top right, #262626, #292929, #262626);
background: #323232;
/*z-index: -1;*/
border-radius: 10px;
}
@ -420,3 +420,20 @@
width: 100%;
grid-gap: 15px;
}
.BTCAddressPopZap {
display: flex;
flex-direction: column;
grid-gap: 0px;
color: #ffffff50;
font-size: 12px;
font-weight: bold;
border-top: solid 1px #ffffff10;
padding: 10px 0 0 0;
margin: 10px 0 0 0;
}
.BTCAddressPopZapTextSpan {
font-weight: normal;
color: #ffffff25;
}

View File

@ -40,6 +40,10 @@
overflow: hidden;
}
.IBMSMSMBSSPostBody > div {
width: 100%;
}
.IBMSMSMBSSPostTitleHeading {
width: 100%;
}
@ -48,6 +52,14 @@
width: 100%;
}
.IBMSMSMBSSPostTitleText > 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);
}
.IBMSMSMBSSPostInside {
display: flex;
flex-direction: column;

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

@ -3,6 +3,11 @@
display: grid;
grid-template-columns: 1fr;
grid-gap: 15px;
max-height: 250px;
overflow: auto;
border-radius: 10px;
border: solid 1px rgba(255,255,255,0.1);
padding: 15px;
}
@media (max-width: 1200px) {

View File

@ -1,3 +1,18 @@
.bodyMain {
background: unset;
display: flex;
flex-direction: column;
align-items: center;
flex-grow: 1;
min-height: 100vh;
position: relative;
letter-spacing: 1px;
font-size: 16px;
line-height: 25px;
word-break: break-word;
color: #ffffff;
}
.ContainerMain {
max-width: 1400px;
width: 100%;
@ -31,6 +46,30 @@ h6 {
margin: 0;
}
h1 {
font-size: 38px;
}
h2 {
font-size: 32px;
}
h3 {
font-size: 24px;
}
h4 {
font-size: 20px;
}
h5 {
font-size: 18px;
}
h6 {
font-size: 16px;
}
.IBMSecMain {
width: 100%;
display: flex;
@ -231,6 +270,33 @@ h6 {
font-size: 16px;
}
/* the 4 classes below here are a temp fix for the games dropdown stylings */
.dropdownMainMenu.dropdownMainMenuAlt {
max-height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div {
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;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
position: relative !important;
left: unset !important;
top: unset !important;
}
.dropdownMainMenuItem {
transition: ease 0.4s;
background: linear-gradient(
@ -249,6 +315,7 @@ h6 {
justify-content: start;
align-items: center;
grid-gap: 10px;
cursor: pointer;
}
.dropdownMainMenuItem:hover {
@ -278,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);
@ -292,6 +391,12 @@ h6 {
flex-direction: row;
grid-gap: 10px;
position: relative;
overflow: hidden;
border-radius: 10px;
}
.inputWrapperMain.inputWrapperMainAlt {
overflow: unset;
}
.inputWrapperMainWrapper {
@ -461,7 +566,7 @@ a {
}
a:hover {
color: rgba(255, 115, 255, 0.75);
color: rgba(255, 115, 255, 0.85);
text-decoration: underline;
}
@ -550,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

@ -7,7 +7,6 @@ export interface LnurlResponse {
minSendable: number
maxSendable: number
commentAllowed: number
payerData: { [key: string]: { [key: string]: boolean } }
tag: string
nostrPubkey: string
allowsNostr: boolean
@ -20,7 +19,6 @@ export const isLnurlResponse = (obj: any): obj is LnurlResponse =>
'minSendable' in obj &&
'maxSendable' in obj &&
'commentAllowed' in obj &&
'payerData' in obj &&
'tag' in obj &&
'nostrPubkey' in obj &&
'allowsNostr' in obj

View File

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

View File

@ -5,6 +5,7 @@ import { RelayController } from '../controllers'
import { log, LogType } from './utils'
import { toast } from 'react-toastify'
import { MOD_FILTER_LIMIT, T_TAG_VALUE } from '../constants'
import DOMPurify from 'dompurify'
/**
* Extracts and normalizes mod data from an event.
@ -133,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.
*
@ -143,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
@ -166,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
@ -184,3 +191,31 @@ export const fetchMods = async (
return [] as ModDetails[] // Return an empty array in case of an error
})
}
/**
* Sanitizes the given HTML string and adds target="_blank" to all <a> tags.
*
* @param htmlString - The HTML string to sanitize and modify.
* @returns The modified HTML string with sanitized content and updated links.
*/
export const sanitizeAndAddTargetBlank = (htmlString: string) => {
// Step 1: Sanitize the HTML string using DOMPurify.
// This removes any potentially dangerous content and ensures that the HTML is safe to use.
const sanitizedHtml = DOMPurify.sanitize(htmlString, { ADD_ATTR: ['target'] })
// Step 2: Create a temporary container (a <div> element) to parse the sanitized HTML.
// This allows us to manipulate the HTML content in a safe and controlled manner.
const tempDiv = document.createElement('div')
tempDiv.innerHTML = sanitizedHtml
// Step 3: Add target="_blank" to all <a> tags within the temporary container.
// This ensures that all links open in a new tab when clicked.
const links = tempDiv.querySelectorAll('a')
links.forEach((link) => {
link.setAttribute('target', '_blank')
})
// Step 4: Convert the manipulated DOM back to an HTML string.
// This string contains the sanitized content with the target="_blank" attribute added to all links.
return tempDiv.innerHTML
}

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).
@ -84,3 +96,126 @@ export const npubToHex = (pubKey: string): string | null => {
// Not a valid hex key
return null
}
/**
* Extracts the zap amount from an event object.
*
* @param event - The event object from which the zap amount needs to be extracted.
* @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 'amount' tag within the parsed description's tags
const amountTag = event.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
// 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

@ -90,3 +90,32 @@ export const getFilenameFromUrl = (url: string): string => {
// Return the extracted filename
return filename
}
/**
* Downloads a file from the given URL.
*
* @param url - The URL of the file to download.
* @param filename - The name of the file to save as.
*/
export const downloadFile = (url: string, filename: string) => {
// Create a temporary anchor element
const a = document.createElement('a')
// Set the href attribute to the file's URL
a.href = url
// Set the download attribute with the desired file name
a.download = filename
// Set target="_blank" to ensure that link opens in new tab
a.setAttribute('target', '_blank')
// Append the anchor to the body (not displayed)
document.body.appendChild(a)
// Programmatically trigger a click event on the anchor to start the download
a.click()
// Remove the anchor from the document
document.body.removeChild(a)
}

View File

@ -97,3 +97,41 @@ export const unformatNumber = (value: string): number => {
// If `parseFloat` fails to parse the string, `|| 0` ensures that the function returns 0.
return parseFloat(value.replace(/,/g, '')) || 0
}
/**
* Formats a number into a more readable string with suffixes.
*
* @param value - The number to be formatted.
* @returns A string representing the formatted number with suffixes.
* - "K" for thousands
* - "M" for millions
* - "B" for billions
* - The number as-is if it's less than a thousand
*/
export const abbreviateNumber = (value: number): string => {
if (value >= 1000000000) {
// Format as billions
return `${(value / 1000000000).toFixed(1)}B`
} else if (value >= 1000000) {
// Format as millions
return `${(value / 1000000).toFixed(1)}M`
} else if (value >= 1000) {
// Format as thousands
return `${(value / 1000).toFixed(1)}K`
} else {
// Format as regular number
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()]
})