Compare commits

..

No commits in common. "staging" and "mods-inner" have entirely different histories.

91 changed files with 2496 additions and 9726 deletions

View File

@ -1,14 +1,4 @@
# 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>
# 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
VITE_ADMIN_NPUBS= <A comma separated list of npubs>

View File

@ -1,40 +0,0 @@
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

@ -1,40 +0,0 @@
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

View File

@ -1,21 +0,0 @@
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,91 +1,11 @@
[DEG Mods](https://degmods.com)
===============================
### Local testing
**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.
- clone the repo
Features
--------
```sh
npm ci
```
- **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.
```sh
npm run dev
```

View File

@ -3,33 +3,23 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="DEG Mods - Liberating Game Mods" />
<meta
property="og:description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<meta property="og:image" content="/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 name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
<!-- Other Meta Tags -->
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
<meta
name="description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<!-- Links and Stylesheets -->
<meta property="og:image" content="/assets/img/DEGM%20Thumb.png" />
<meta
name="twitter:description"
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
/>
<meta property="og:type" content="website" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css"
/>
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
<link
@ -38,12 +28,25 @@
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="/assets/bootstrap/js/bootstrap.min.js"></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>
</body>
</html>

1427
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,40 +10,27 @@
"preview": "vite preview"
},
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk": "2.8.2",
"@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",
"papaparse": "5.4.1",
"qrcode.react": "3.1.0",
"react": "^18.3.1",
"react-countdown": "2.3.5",
"react-dom": "^18.3.1",
"react-quill": "2.0.0",
"react-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"
"uuid": "10.0.0"
},
"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",
@ -58,7 +45,6 @@
"eslint-plugin-react-refresh": "^0.4.7",
"ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2",
"vite": "^5.3.1",
"vite-tsconfig-paths": "5.0.1"
"vite": "^5.3.1"
}
}

View File

@ -1,12 +0,0 @@
{
"names": {
"degmods": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
"degmodsreposter": "7382a4cc21742ac3e3581f1c653a41f912e985e6a941439377803b866042e53f",
"freakoverse": "3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40",
"nostrdev": "27487c9600b16b24a1bfb0519cfe4a5d1ad84959e3cce5d6d7a99d48660a1f78",
"Merlin": "76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698",
"makano": "fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10",
"reya": "126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede"
}
}

View File

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

View File

@ -1,3 +0,0 @@
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

@ -1,4 +0,0 @@
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

@ -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.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"/>
<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"/>
</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,9 +22,11 @@
</g>
</g>
<g>
<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"/>
<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"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@ -0,0 +1,11 @@
<?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>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,27 +1,8 @@
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,12 +3,14 @@ import navStyles from '../styles/nav.module.scss'
export const Banner = () => {
return (
<div className={navStyles.FundingCampaign}>
<p
<a
className={navStyles.FundingCampaignLink}
href='https://geyser.fund/project/degmods'
target='_blank'
>
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>
DEG Mods is running a crowd funding campaign. Chip-in or share the link
to help bring the project to life (click me).
</a>
</div>
)
}

View File

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

View File

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

View File

@ -1,9 +1,38 @@
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 React from 'react'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
import '../styles/customQuillStyles.css'
import '../styles/styles.css'
import '../styles/tiptap.scss'
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']
]
}
interface InputFieldProps {
label: string
@ -48,9 +77,13 @@ export const InputField = React.memo(
onChange={handleChange}
></textarea>
) : type === 'richtext' ? (
<RichTextEditor
content={value}
updateContent={(content) => onChange(name, content)}
<ReactQuill
className='inputMain'
formats={editorFormats}
modules={editorModules}
placeholder={placeholder}
value={value}
onChange={(content) => onChange(name, content)}
/>
) : (
<input
@ -105,186 +138,3 @@ 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,89 +1,72 @@
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
imageUrl: string
route: string
backgroundLink: string
handleClick: () => void
}
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>
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>
</div>
</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 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>
</div>
</Link>
)
}
)
</div>
</a>
)
}

View File

@ -1,5 +1,6 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import Papa from 'papaparse'
import React, {
Fragment,
useCallback,
@ -8,16 +9,11 @@ import React, {
useRef,
useState
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { FixedSizeList as List } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../constants'
import { RelayController } from '../controllers'
import { useAppSelector, useGames } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { useAppSelector } from '../hooks'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import {
initializeFormState,
isReachable,
@ -28,7 +24,12 @@ 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
@ -47,14 +48,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)
@ -62,21 +63,36 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
const [formState, setFormState] = useState<ModFormState>(
initializeFormState(existingModData)
)
const [formErrors, setFormErrors] = useState<FormErrors>({})
useEffect(() => {
if (location.pathname === appRoutes.submitMod) {
setFormState(initializeFormState())
}
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
if (processedCSV) return
processedCSV = true
useEffect(() => {
const options = games.map((game) => ({
label: game['Game Name'],
value: game['Game Name']
}))
setGameOptions(options)
}, [games])
// 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))
}, [])
const handleInputChange = useCallback((name: string, value: string) => {
setFormState((prevState) => ({
@ -169,7 +185,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
if (userState.isAuth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
@ -188,9 +204,6 @@ 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,
@ -198,7 +211,10 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
content: formState.body,
tags: [
['d', formState.dTag || uuid],
['a', aTag],
[
'a',
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
@ -251,14 +267,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
)}`
)
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
const nevent = nip19.neventEncode({
id: signedEvent.id,
author: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
navigate(getModPageRoute(naddr))
navigate(getModsInnerPageRoute(nevent))
}
setIsPublishing(false)
@ -321,7 +337,10 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
} else {
for (let i = 0; i < formState.downloadUrls.length; i++) {
const downloadUrl = formState.downloadUrls[i]
if (!isValidUrl(downloadUrl.url)) {
if (
!isValidUrl(downloadUrl.url) ||
!(await isReachable(downloadUrl.url))
) {
if (!errors.downloadUrls)
errors.downloadUrls = Array(formState.downloadUrls.length)
@ -761,7 +780,7 @@ const GameDropdown = ({
can add it.
</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<div className='inputWrapperMain'>
<input
ref={inputRef}
type='text'
@ -793,7 +812,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 dropdownMainMenuAlt'>
<div className='dropdown-menu dropdownMainMenu'>
<List
height={500}
width={'100%'}

View File

@ -1,138 +0,0 @@
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,206 +1,174 @@
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import {
MetadataController,
RelayController,
UserRelaysType
} from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import { getProfilePageRoute } from '../routes'
import '../styles/author.css'
import '../styles/innerPage.css'
import '../styles/socialPosts.css'
import { UserProfile } from '../types'
import { copyTextToClipboard, log, LogType, now, npubToHex } from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap'
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import _ from 'lodash'
type Props = {
pubkey: string
}
export const ProfileSection = ({ pubkey }: Props) => {
const [profile, setProfile] = useState<UserProfile>()
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(pubkey).then((res) => {
setProfile(res)
})
})
if (!profile) return null
export const ProfileSection = () => {
return (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile profile={profile} />
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
{posts.map((post, index) => (
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_Author'>
<div className='IBMSMSMSSS_Author_Top'>
<div className='IBMSMSMSSS_Author_Top_Left'>
<a
key={'post' + index}
className='IBMSMSMSSS_ShortPostsPostLink'
href={post.link}
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
href='profile.html'
>
<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_Author_Top_Left_Inside'>
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
<div
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
background:
"url('/assets/img/media-cache%20(4).png') center / cover no-repeat"
}}
></div>
)}
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>
{author.name}
</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>
{author.handle}
</p>
</div>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</div>
)
}
type ProfileProps = {
profile: NDKUserProfile
}
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_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
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>
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>
</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 className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
<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} />
id='OwnerFollowLogin'
className='IBMSMSMSSS_Author_Top_NostrLinks'
style={{ display: 'flex' }}
></div>
</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>
<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>
<FollowButton pubkey={hexPubkey} />
</div>
)
}
interface Author {
name: string
handle: string
address: string
bio: string
}
const author: Author = {
name: 'Freakoverse',
handle: 'freakoverse@degmods.com',
address: 'npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r',
bio: `I guess I'm one of those #vtubers . Having fun talking about general topics, vrchat/similar, and games. Also #indiedev #gamedev You can call me: Freak فْرِيكٌ <20><><EFBFBD>リク (still learning Nihongo). #envtuber #podcast #gaming #gamedev`
}
interface Post {
name: string
link: string
@ -210,330 +178,47 @@ interface Post {
const posts: Post[] = [
{
name: 'User name',
name: 'Freakoverse',
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`
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`
},
{
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',
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',
name: 'Freakoverse',
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`,
imageUrl: '/assets/img/DEGMods%20Placeholder%20Img.png'
content: `This is good.`,
imageUrl: '/assets/img/media-cache%20(1).jpg'
}
]
type QRButtonWithPopUpProps = {
pubkey: string
}
const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
const nprofile = nip19.nprofileEncode({
pubkey
})
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
const a = document.createElement('a')
a.href = href
a.target = '_blank' // Open in a new tab
a.rel = 'noopener noreferrer' // Recommended for security reasons
a.click()
}
return (
<>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
{isOpen && (
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Nostr Address</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => setIsOpen(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='popUpMainCardBottom'>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={nprofile}
height={235}
width={235}
/>
</div>
</div>
</div>
</div>
</div>
)}
</>
)
}
type ZapButtonWithPopUpProps = {
pubkey: string
}
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
{isOpen && (
<ZapPopUp
title='Tip/Zap'
receiver={pubkey}
handleClose={() => setIsOpen(false)}
/>
)}
</>
)
}
type FollowButtonProps = {
pubkey: string
}
const FollowButton = ({ pubkey }: FollowButtonProps) => {
const [isFollowing, setIsFollowing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const userState = useAppSelector((state) => state.user)
useDidMount(async () => {
if (userState.auth && userState.user?.pubkey) {
const userHexKey = userState.user.pubkey as string
const { isFollowing: isAlreadyFollowing } = await checkIfFollowing(
userHexKey,
pubkey
)
setIsFollowing(isAlreadyFollowing)
}
})
const getUserPubKey = async (): Promise<string | null> => {
if (userState.auth && userState.user?.pubkey) {
return userState.user.pubkey as string
} else {
return (await window.nostr?.getPublicKey()) as string
}
}
const checkIfFollowing = async (
userHexKey: string,
pubkey: string
): Promise<{
isFollowing: boolean
tags: string[][]
}> => {
const filter: Filter = {
kinds: [kinds.Contacts],
authors: [userHexKey]
}
const contactListEvent =
await RelayController.getInstance().fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Both
)
if (!contactListEvent)
return {
isFollowing: false,
tags: []
}
return {
isFollowing: contactListEvent.tags.some(
(t) => t[0] === 'p' && t[1] === pubkey
),
tags: contactListEvent.tags
}
}
const signAndPublishEvent = async (
unsignedEvent: UnsignedEvent
): Promise<boolean> => {
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) return false
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
return false
}
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
return true
}
const handleFollow = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Processing follow request')
const userHexKey = await getUserPubKey()
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return
}
const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing(
userHexKey,
pubkey
)
if (isAlreadyFollowing) {
toast.info('Already following!')
setIsFollowing(true)
setIsLoading(false)
return
}
const unsignedEvent: UnsignedEvent = {
content: '',
created_at: now(),
kind: kinds.Contacts,
pubkey: userHexKey,
tags: [...tags, ['p', pubkey]]
}
setLoadingSpinnerDesc('Signing and publishing follow event')
const success = await signAndPublishEvent(unsignedEvent)
setIsFollowing(success)
setIsLoading(false)
}
const handleUnFollow = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Processing unfollow request')
const userHexKey = await getUserPubKey()
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return
}
const filter: Filter = {
kinds: [kinds.Contacts],
authors: [userHexKey]
}
const contactListEvent =
await RelayController.getInstance().fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Both
)
if (
!contactListEvent ||
!contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey)
) {
// could not found target pubkey in user's follow list
// so, just update the status and return
setIsFollowing(false)
setIsLoading(false)
return
}
const unsignedEvent: UnsignedEvent = {
content: '',
created_at: now(),
kind: kinds.Contacts,
pubkey: userHexKey,
tags: contactListEvent.tags.filter(
(t) => !(t[0] === 'p' && t[1] === pubkey)
)
}
setLoadingSpinnerDesc('Signing and publishing unfollow event')
const success = await signAndPublishEvent(unsignedEvent)
setIsFollowing(!success)
setIsLoading(false)
}
return (
<>
<button
className='btn btnMain'
type='button'
onClick={isFollowing ? handleUnFollow : handleFollow}
>
{isFollowing ? 'Un-Follow' : 'Follow'}
</button>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}

View File

@ -1,462 +0,0 @@
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,135 +1 @@
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

@ -1,3 +1,2 @@
export * from './metadata'
export * from './relay'
export * from './zap'

View File

@ -1,14 +1,8 @@
import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk'
import NDK, { NDKList, NDKRelayList, NDKUser } from '@nostr-dev-kit/ndk'
import { UserProfile } from '../types/user'
import { hexToNpub, log, LogType, npubToHex } from '../utils'
import { kinds } from 'nostr-tools'
import { MuteLists } from '../types'
import { UserProfile } from '../types/user'
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.
@ -16,32 +10,20 @@ export enum UserRelaysType {
export class MetadataController {
private static instance: MetadataController
private ndk: NDK
private usersMetadata = new Map<string, UserProfile>()
public adminNpubs: string[]
public adminRelays = new Set<string>()
public reportingNpub: string
private constructor() {
this.ndk = new NDK({
explicitRelayUrls: [
'wss://user.kindpag.es',
'wss://purplepag.es',
'wss://relay.damus.io/',
import.meta.env.VITE_APP_RELAY
'wss://relay.damus.io/'
]
})
this.ndk
.connect()
.then(() => {
console.log('NDK connected')
})
.catch((err) => {
console.log('error in ndk connection', err)
})
this.ndk.connect()
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB
}
private setAdminRelays = async () => {
@ -49,7 +31,7 @@ export class MetadataController {
const hexKey = npubToHex(npub)
if (!hexKey) return null
return getRelayListForUser(hexKey, this.ndk)
return NDKRelayList.forUser(hexKey, this.ndk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.writeRelayUrls.forEach((url) =>
@ -79,7 +61,7 @@ export class MetadataController {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
await MetadataController.instance.setAdminRelays()
MetadataController.instance.setAdminRelays()
}
return MetadataController.instance
}
@ -92,159 +74,54 @@ export class MetadataController {
*/
public findMetadata = async (pubkey: string): Promise<UserProfile> => {
const npub = hexToNpub(pubkey)
const cachedMetadata = this.usersMetadata.get(npub)
if (cachedMetadata) {
return cachedMetadata
}
const user = new NDKUser({ npub })
user.ndk = this.ndk
const userProfile = await user.fetchProfile()
if (userProfile) {
this.usersMetadata.set(npub, userProfile)
return await user.fetchProfile()
}
public findWriteRelays = async (hexKey: string) => {
const ndkRelayList = await NDKRelayList.forUser(hexKey, this.ndk)
if (!ndkRelayList) {
throw new Error(`Couldn't found user's relay list`)
}
return userProfile
return ndkRelayList.writeRelayUrls
}
/**
* Finds metadata for admin user.
*
* @returns A promise that resolves to the metadata event.
*/
public findAdminMetadata = async (): Promise<UserProfile> => {
return this.findMetadata(this.adminNpubs[0])
}
public getAdminsMuteLists = async (): Promise<MuteLists> => {
// Create a Set to collect all unique muted authors
public findUserRelays = async (
hexKey: string,
userRelaysType: UserRelaysType = UserRelaysType.Both
): Promise<string[]> => {
log(true, LogType.Info, ` Finding user's relays`, hexKey, userRelaysType)
const mutedAuthors = new Set<string>()
const ndkRelayListPromise = getRelayListForUser(hexKey, this.ndk)
// 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
// 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 getMuteLists = async (
pubkey?: string
): Promise<{
admin: MuteLists
user: MuteLists
}> => {
const adminMutedAuthors = new Set<string>()
const adminMutedPosts = new Set<string>()
const adminHexKey = npubToHex(this.reportingNpub)
if (adminHexKey) {
const muteListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Mutelist],
authors: [adminHexKey]
authors: [hexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
// Add muted authors to the Set directly
if (item[0] === 'p') {
adminMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
adminMutedPosts.add(item[1])
mutedAuthors.add(item[1])
}
})
}
}
})
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])
}
})
}
}
}
await Promise.allSettled(promises)
return {
admin: {
authors: Array.from(adminMutedAuthors),
replaceableEvents: Array.from(adminMutedPosts)
},
user: {
authors: Array.from(userMutedAuthors),
replaceableEvents: Array.from(userMutedPosts)
}
authors: Array.from(mutedAuthors),
eventIds: []
}
}
/**
* 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,37 +1,18 @@
import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools'
import {
extractZapAmount,
log,
LogType,
normalizeWebSocketURL,
timeout
} from '../utils'
import { MetadataController, UserRelaysType } from './metadata'
import { Event, Filter, Relay } from 'nostr-tools'
import { log, LogType, normalizeWebSocketURL, timeout } from '../utils'
import { MetadataController } from './metadata'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
private events = new Map<string, Event>()
private debug = true
public connectedRelays: Relay[] = []
private constructor() {}
/**
* Provides the singleton instance of RelayController.
*
* @returns The singleton instance of RelayController.
*/
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
return RelayController.instance
}
public connectRelay = async (relayUrl: string) => {
private connectRelay = async (relayUrl: string) => {
const relay = this.connectedRelays.find(
(relay) =>
normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl)
@ -59,118 +40,21 @@ export class RelayController {
}
/**
* Publishes an event to multiple relays.
* Provides the singleton instance of RelayController.
*
* 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.
* @returns The singleton instance of RelayController.
*/
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 []
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
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
return RelayController.instance
}
/**
* Publishes an encrypted DM to receiver's read relays.
* Publishes an event to multiple relays.
*
* This method connects to the application relay and a set of receiver's read relays
* This method connects to the application relay and a set of write relays
* obtained from the `MetadataController`. It then publishes the event to
* all connected relays and returns a list of relays where the event was successfully published.
*
@ -178,7 +62,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.
*/
publishDM = async (event: Event, receiver: string): Promise<string[]> => {
publish = async (event: Event): Promise<string[]> => {
// Connect to the application relay specified by environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
@ -186,19 +70,28 @@ export class RelayController {
const metadataController = await MetadataController.getInstance()
// Retrieve the list of read relays for the receiver
const readRelayUrls = await metadataController.findUserRelays(
receiver,
UserRelaysType.Read
)
// 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)
// push admin relay urls obtained from metadata controller to readRelayUrls list
log(this.debug, LogType.Info, ` Finding user's write relays`)
// Use Promise.race to either get the write relay URLs or timeout
const writeRelayUrls = await Promise.race([
writeRelaysPromise,
timeout() // This is a custom timeout function that rejects the promise after a specified time
]).catch((err) => {
log(this.debug, LogType.Error, err)
return [] as string[] // Return an empty array if an error occurs
})
// push admin relay urls obtained from metadata controller to writeRelayUrls list
metadataController.adminRelays.forEach((url) => {
readRelayUrls.push(url)
writeRelayUrls.push(url)
})
// Connect to all write relays obtained from MetadataController
const relayPromises = readRelayUrls.map((relayUrl) =>
const relayPromises = writeRelayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
@ -252,112 +145,6 @@ 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.
@ -368,46 +155,24 @@ export class RelayController {
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
relays: string[] = []
): Promise<Event[]> => {
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)
// add app relay to relays array
relays.push(import.meta.env.VITE_APP_RELAY)
const metadataController = await MetadataController.getInstance()
// add admin relays to relays array
metadataController.adminRelays.forEach((relayUrl) => {
relaySet.add(relayUrl)
metadataController.adminRelays.forEach((url) => {
relays.push(url)
})
relayUrls = Array.from(relaySet)
// 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
}, [])
const relayPromises = relays.map((relayUrl) => this.connectRelay(relayUrl))
await Promise.allSettled(relayPromises)
// Check if any relays are connected
if (relays.length === 0) {
if (this.connectedRelays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected to fetch events!')
throw new Error('No relay is connected to fetch events!')
}
@ -415,7 +180,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 = relays.map((relay) => {
const subPromises = this.connectedRelays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
@ -463,296 +228,12 @@ 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)
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
}
// Return the most recent event, or null if no events were received
return events[0] || null
}
}

View File

@ -1,355 +0,0 @@
import { Invoice } from '@getalby/lightning-tools'
import axios, { AxiosInstance } from 'axios'
import { Filter, kinds } from 'nostr-tools'
import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln'
import {
isLnurlResponse,
LnurlResponse,
PaymentRequest,
SignedEvent,
ZapReceipt,
ZapRequest
} from '../types'
import { log, LogType, npubToHex } from '../utils'
import { RelayController } from './relay'
import { MetadataController, UserRelaysType } from './metadata'
/**
* Singleton class to manage zap related operations.
*/
export class ZapController {
private static instance: ZapController
private webln: WebLNProvider | null = null
private httpClient: AxiosInstance
private appRelay = import.meta.env.VITE_APP_RELAY
private constructor() {
this.httpClient = axios.create()
}
/**
* @returns The singleton instance of ZapController.
*/
public static getInstance(): ZapController {
if (!ZapController.instance) {
ZapController.instance = new ZapController()
}
return ZapController.instance
}
/**
* Generates ZapRequest and payment request string. More info can be found at
* https://github.com/nostr-protocol/nips/blob/master/57.md.
* @param lud16 - LUD-16 of the recipient.
* @param amount - payment amount (will be multiplied by 1000 to represent sats).
* @param recipientPubKey - pubKey of the recipient.
* @param senderPubkey - pubKey of of the sender.
* @param content - optional content (comment).
* @param eventId - event id, if zapping an event.
* @param aTag - value of `a` tag.
* @returns - promise that resolves into object containing zap request and payment
* request string
*/
async getLightningPaymentRequest(
lud16: string,
amount: number,
recipientPubKey: string,
senderPubkey: string,
content?: string,
eventId?: string,
aTag?: string
) {
// Check if amount is greater than 0
if (amount <= 0) throw 'Amount should be > 0.'
// convert to mili satoshis
amount *= 1000
// decode lud16 into lnurl
const lnurl = this.decodeLud16(lud16)
// get receiver lightning details from lnurl pay endpoint
const lnurlResponse = await this.getLnurlResponse(lnurl)
const { minSendable, maxSendable, callback } = lnurlResponse
// check if the amount is within minSendable and maxSendable values
if (amount < minSendable || amount > maxSendable) {
throw `Amount '${amount}' is not within minSendable and maxSendable values '${minSendable}-${maxSendable}'.`
}
// generate zap request
const zapRequest = await this.createZapRequest(
amount,
content,
recipientPubKey,
senderPubkey,
eventId,
aTag
)
if (!window.nostr?.signEvent) {
log(
true,
LogType.Error,
'Failed to sign the zap request!',
'window.nostr.signEvent is not defined'
)
throw 'Failed to sign zap Request!'
}
// Sign zap request. This is validated by the lightning provider prior to sending the invoice(NIP-57).
const signedEvent = await window.nostr
.signEvent(zapRequest)
.then((event) => event as SignedEvent)
.catch((err) => {
log(true, LogType.Error, 'Failed to sign the zap request!', err)
throw 'Failed to sign the zap request!'
})
// Kind 9734 event must be signed and sent
// in order to receive the invoice from the provider.
// Encode stringified signed zap request.
const encodedEvent = encodeURI(JSON.stringify(signedEvent))
// send zap request as GET request to callback url received from the lnurl pay endpoint
const { data } = await this.httpClient.get(
`${callback}?amount=${amount}&nostr=${encodedEvent}`
)
// data object of the response should contain payment request
if (data && data.pr) {
return Promise.resolve({ ...signedEvent, pr: data.pr })
}
throw 'lnurl callback did not return payment request.'
}
/**
* Polls zap receipt.
* @param paymentRequest - payment request object containing zap request and
* payment request string.
* @param pollingTimeout - polling timeout (secs), by default equals to 6min.
* @returns - promise that resolves into zap receipt.
*/
async pollZapReceipt(
paymentRequest: PaymentRequest,
pollingTimeout?: number
) {
const { pr, ...zapRequest } = paymentRequest
const { created_at } = zapRequest
// stringify zap request
const zapRequestStringified = JSON.stringify(zapRequest)
// eslint-disable-next-line no-async-promise-executor
return new Promise<ZapReceipt>(async (resolve, reject) => {
// clear polling timeout
const cleanup = () => {
clearTimeout(timeout)
subscriptions.forEach((subscription) => subscription.close())
}
// Polling timeout
const timeout = setTimeout(
() => {
cleanup()
reject('Zap receipt was not received.')
},
pollingTimeout || 6 * 60 * 1000 // 6 minutes
)
const relaysTag = zapRequest.tags.find((t) => t[0] === 'relays')
if (!relaysTag)
throw new Error('Zap request does not contain relays tag.')
const relayUrls = relaysTag.slice(1)
// filter relay for event of kind 9735
const filter: Filter = {
kinds: [kinds.Zap],
since: created_at
}
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()
resolve(event as ZapReceipt)
}
}
}
)
})
}
async isWeblnProviderExists(): Promise<boolean> {
await this.requestWeblnProvider()
return !!this.webln
}
async sendPayment(invoice: string): Promise<SendPaymentResponse> {
if (this.webln) {
return await this.webln!.sendPayment(invoice).catch((err) => {
throw new Error(`Error while sending payment. Error: ${err.message}`)
})
}
throw 'Webln is not defined!'
}
/**
* Decodes LUD-16 into lnurl.
* @param lud16 - LUD-16 that looks like <username>@<domainname>.
* @returns - lnurl that looks like 'http://<domain>/.well-known/lnurlp/<username>'.
*/
private decodeLud16(lud16: string) {
const username = lud16.split('@')[0]
const domain = lud16.split('@')[1]
if (!domain || !username) throw `Provided lud16 '${lud16}' is not valid.`
return `https://${domain}/.well-known/lnurlp/${username}`
}
/**
* Fetches and validates response from lnurl pay endpoint.
*
* @param lnurl - lnurl pay endpoint.
* @returns response object that conforms to LnurlResponse interface.
*/
private async getLnurlResponse(lnurl: string): Promise<LnurlResponse> {
// get request from lnurl pay endpoint
const { data: lnurlResponse } = await this.httpClient.get(lnurl)
// validate lnurl response
this.validateLnurlResponse(lnurlResponse)
// return callback URL
return Promise.resolve(lnurlResponse)
}
/**
* Checks if response conforms to LnurlResponse interface and if 'allowsNostr'
* and 'nostrPubkey' supported.
*
* @param response - response received from lnurl pay endpoint.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private validateLnurlResponse(response: any) {
if (!isLnurlResponse(response)) {
throw 'Provided response is not LnurlResponse.'
}
if (!response.allowsNostr) throw `'allowsNostr' is not supported.`
if (!response.nostrPubkey) throw `'nostrPubkey' is not supported.`
}
/**
* Constructs zap request object.
* @param amount - request amount (sats).
* @param content - comment.
* @param recipientPubKey - pubKey of the recipient.
* @param senderPubkey - pubKey of of the sender.
* @param eventId - event id, if zapping an event.
* @param aTag - value of `a` tag.
* @returns zap request
*/
private async createZapRequest(
amount: number,
content = '',
recipientPubKey: string,
senderPubkey: 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', ...receiverReadRelays],
['amount', `${amount}`],
['p', recipientHexKey]
],
pubkey: senderPubkey,
created_at: Math.round(Date.now() / 1000)
}
// add event id to the tags, if zapping an event.
if (eventId) zapRequest.tags.push(['e', eventId])
if (aTag) zapRequest.tags.push(['a', aTag])
return zapRequest
}
/**
* Validates zap receipt preimage and payment request string
* @param paymentRequest - payment request string
* @param event - zap receipt.
* @returns - boolean indicating if preimage in zap receipt is valid
*/
private async validateZapReceipt(paymentRequest: string, event: ZapReceipt) {
const invoice = new Invoice({ pr: paymentRequest })
return invoice.validatePreimage(this.getPreimageFromZapReceipt(event))
}
/**
* Gets preimage from zap receipt.
* @param event - zap receipt (9735 kind).
* @returns - preimage string.
*/
private getPreimageFromZapReceipt(event: ZapReceipt) {
// filter tags by 1st item
const preimageTag = event.tags.filter((tag) => tag[0] === 'preimage')[0]
// throw an error if 'preimage' tag is not present
if (!preimageTag || preimageTag.length != 2) {
throw `'preimage' tag is not present.`
}
const preimage = preimageTag[1]
// throw an error if 'preimage' value is not present
if (!preimage) throw `'preimage' tag is not valid.`
return preimage
}
private async requestWeblnProvider() {
if (!this.webln)
this.webln = await requestProvider().catch((err) => {
console.log('err in requesting WebLNProvider :>> ', err.message)
return null
})
}
}

View File

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

View File

@ -1,81 +0,0 @@
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
}

View File

@ -1,37 +0,0 @@
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
}

View File

@ -1,174 +0,0 @@
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,12 +43,10 @@ a:hover {
color: #535bf2;
}
/*
h1 {
font-size: 3.2em;
line-height: 1.1;
}
*/
button {
border-radius: 8px;

View File

@ -1,18 +1,16 @@
import navStyles from '../styles/nav.module.scss'
import mainStyles from '../styles//main.module.scss'
import { Banner } from '../components/Banner'
import { Link } from 'react-router-dom'
import { appRoutes } from '../routes'
import {
init as initNostrLogin,
launch as launchNostrLoginDialog
} from 'nostr-login'
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Banner } from '../components/Banner'
import { ZapPopUp } from '../components/Zap'
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../hooks'
import { setIsAuth, setUser } from '../store/reducers/user'
import { MetadataController } from '../controllers'
import { useAppDispatch, useAppSelector, useDidMount } from '../hooks'
import { appRoutes } from '../routes'
import { setAuth, setUser } from '../store/reducers/user'
import mainStyles from '../styles//main.module.scss'
import navStyles from '../styles/nav.module.scss'
import '../styles/popup.css'
import { npubToHex } from '../utils'
export const Header = () => {
@ -22,25 +20,15 @@ export const Header = () => {
useEffect(() => {
initNostrLogin({
darkMode: true,
localSignup: true,
noBanner: true,
methods: ['extension'],
onAuth: (npub, opts) => {
if (opts.type === 'logout') {
dispatch(setAuth(null))
dispatch(setIsAuth(false))
dispatch(setUser(null))
} else {
dispatch(
setAuth({
method: opts.method,
localNsec: opts.localNsec
})
)
dispatch(
setUser({
npub,
pubkey: npubToHex(npub)!
})
)
dispatch(setIsAuth(true))
dispatch(setUser({ npub }))
MetadataController.getInstance().then((metadataController) => {
metadataController.findMetadata(npub).then((userProfile) => {
if (userProfile) {
@ -84,7 +72,20 @@ export const Header = () => {
</div>
<div className={navStyles.NMTI_Sec}>
<div className={navStyles.NMTI_SecInside}>
<TipButtonWithDialog />
<a
className={`${navStyles.NMTI_SecInside_Link} ${navStyles.NMTI_SI_LinkTip}`}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
Tip
</a>
<Link
to={appRoutes.submitMod}
className={navStyles.NMTI_SecInside_Link}
@ -130,23 +131,20 @@ export const Header = () => {
</svg>
Settings
</Link>
{!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 && (
<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 && userState.user && (
{userState.isAuth && userState.user && (
<div className={navStyles.NMTI_SecInside_Link}>
{userState.user.image && (
<img
@ -170,267 +168,33 @@ export const Header = () => {
<div className={navStyles.NavMainBottom}>
<div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainBottomInside}>
<div
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}
></div>
<div className={navStyles.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}`}
<Link
to={appRoutes.games}
className={navStyles.NavMainBottomInsideLink}
>
<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>
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>
</div>
</div>
)
}
const TipButtonWithDialog = React.memo(() => {
const [adminNpub, setAdminNpub] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
setAdminNpub(metadataController.adminNpubs[0])
})
return (
<>
<a
className={`${navStyles.NMTI_SecInside_Link} ${navStyles.NMTI_SI_LinkTip}`}
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
Tip
</a>
{isOpen && adminNpub && (
<ZapPopUp
title='Tip/Zap DEG Mods'
receiver={adminNpub}
handleClose={() => setIsOpen(false)}
labelDescriptionMain={
<p className='labelDescriptionMain' style={{ textAlign: 'center' }}>
If you don't want the development and maintenance of DEG Mods to
stop, then a tip helps!
</p>
}
lastNode={
<div className='BTCAddressPopZap'>
<p>
DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '}
<a
href='https://youtu.be/payDPlHzp58?t=215'
className='linkMain'
target='_blank'
>
Learn more
</a>
):
<br />
<span className='BTCAddressPopZapTextSpan'>
sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd
</span>
</p>
</div>
}
/>
)}
</>
)
})
const RegisterButtonWithDialog = () => {
const [showPopUp, setShowPopUp] = useState(false)
return (
<>
<a
id='registerNav'
className={navStyles.NMTI_SecInside_Link}
onClick={() => setShowPopUp(true)}
>
Register
</a>
{showPopUp && (
<div id='PopUpMainRegister' className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Create an account via</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => setShowPopUp(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Browser Extensions (Windows)
</label>
<p className='labelDescriptionMain'>
Once you create your "account" on any of these, come
back and click login, then sign-in with extension.
</p>
</div>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://chromewebstore.google.com/detail/nostr-connect/ampjiinddmggbhpebhaegmjkbbeofoaj'
target='_blank'
>
Nostr Connect
</a>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://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>
<a
className='btn btnMain btnMainPopup'
role='button'
href='https://apps.apple.com/us/app/nostore/id1666553677'
target='_blank'
>
Nostore Browser Extension
</a>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

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

View File

@ -1,115 +0,0 @@
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 { HashRouter } from 'react-router-dom'
import { BrowserRouter } 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}>
<HashRouter>
<BrowserRouter>
<App />
<ToastContainer />
</HashRouter>
</BrowserRouter>
</Provider>
</React.StrictMode>
)

View File

@ -10,67 +10,55 @@ export type FAQItem = {
const FAQ_ITEMS: FAQItem[] = [
{
question: "You don't host mod files?",
answer: `We don't handle that directly, but you, as the creator, will.`
question: "You don't host mods files?",
answer: "Nope. And that's for the better."
},
{
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: '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: "Why are you quoting 'account'?",
answer: `We use 'account' in quotes because technically you're generating a
key pair, not creating a traditional account. The next FAQ explains more.`
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."
},
{
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 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.`
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."
},
{
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:
"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: '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:
'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: 'Is this an open-source project?',
answer: `Yes, DEG Mods is open-source. You can access the code repository
[here](https://github.com/your-repo).`
answer: "Yes. Here's the 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](https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r), and the co-developer
is [Nostr Dev](https://nostrdev.com/).`
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.'
},
{
question: "Who's that character above with the orange hair?",
answer: `That's Vivian James, a fictional gamer character.`
answer: "That's Vivian James. A gamer that just wants to game in peace."
},
{
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 (
@ -120,7 +108,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='https://nostr.com/' target="_blank">
<a className='linkMain' href='#'>
Learn more about Nostr here.
</a>
<br />
@ -171,9 +159,7 @@ 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, 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)
it). This is just a passion project to help free (liberate)
game mods and their creators, and this part potentially
helps them financially, even those in other countries where
"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 (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>Blogs</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
@ -92,9 +92,9 @@ export const BlogsPage = () => {
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
<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='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' />

View File

@ -1,299 +0,0 @@
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,51 +1,9 @@
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'
import '../styles/search.css'
import { GameCard } from '../components/GameCard'
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'>
@ -58,18 +16,8 @@ export const GamesPage = () => {
<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}
>
<input type='text' className='SMIWInput' />
<button className='btn btnMain SMIWButton' type='button'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
@ -87,20 +35,51 @@ export const GamesPage = () => {
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
{currentGames.map((game) => (
<GameCard
key={game['Game Name']}
title={game['Game Name']}
imageUrl={game['Boxart image']}
/>
))}
<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>
</div>
</div>
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
</div>
</div>
</div>

View File

@ -1,61 +1,123 @@
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'>
<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 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>
</div>
</div>
</div>
@ -67,19 +129,17 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Cool Games</h2>
</div>
<div className='IBMSMList IBMSMListFeaturedAlt'>
{LANDING_PAGE_DATA.featuredGames.map((game) => (
<GameCard
key={game.title}
title={game.title}
imageUrl={game.imageUrl}
/>
))}
<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' />
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
onClick={() => navigate(appRoutes.games)}
href='blog.html'
>
View All
</a>
@ -90,29 +150,77 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Awesome Mods</h2>
</div>
<div className='IBMSMList IBMSMListAlt'>
{LANDING_PAGE_DATA.awesomeMods.map((naddr) => (
<DisplayMod key={naddr} naddr={naddr} />
))}
<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'
/>
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
onClick={() => navigate(appRoutes.mods)}
href='blog.html'
>
View All
</a>
</div>
</div>
<DisplayLatestMods />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
<h2 className='IBMSMTitleMainHeading'>Latest Mods</h2>
</div>
<div className='IBMSMList'>
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<BlogCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<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' />
</div>
@ -131,197 +239,3 @@ 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>
)
}

917
src/pages/innerMod.tsx Normal file
View File

@ -0,0 +1,917 @@
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}
/>
<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[]
}
const Body = ({
featuredImageUrl,
title,
body,
screenshotsUrls,
tags
}: 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'>
{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>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,592 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,256 +0,0 @@
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,4 +1,3 @@
import { Pagination } from 'components/Pagination'
import { kinds, nip19 } from 'nostr-tools'
import React, {
Dispatch,
@ -8,17 +7,17 @@ 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 { useAppSelector, useDidMount, useMuteLists } from '../hooks'
import { getModPageRoute } from '../routes'
import { useDidMount } from '../hooks'
import { getModsInnerPageRoute } from '../routes'
import '../styles/filters.css'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import { ModDetails } from '../types'
import { ModDetails, MuteLists } from '../types'
import { fetchMods } from '../utils'
enum SortBy {
@ -36,8 +35,7 @@ enum NSFWFilter {
enum ModeratedFilter {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated',
Unmoderated_Fully = 'Unmoderated Fully'
Unmoderated = 'Unmoderated'
}
interface FilterOptions {
@ -48,6 +46,7 @@ interface FilterOptions {
}
export const ModsPage = () => {
const navigate = useNavigate()
const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
@ -56,25 +55,23 @@ export const ModsPage = () => {
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const muteLists = useMuteLists()
const [nsfwList, setNSFWList] = useState<string[]>([])
const [muteLists, setMuteLists] = useState<MuteLists>({
authors: [],
eventIds: []
})
const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user)
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.getNSFWList().then((list) => {
setNSFWList(list)
metadataController.getAdminsMuteLists().then((lists) => {
setMuteLists(lists)
})
})
useEffect(() => {
setIsFetching(true)
fetchMods({ source: filterOptions.source })
fetchMods(filterOptions.source)
.then((res) => {
setMods(res)
})
@ -87,12 +84,9 @@ export const ModsPage = () => {
setIsFetching(true)
const until =
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
mods.length > 0 ? mods[mods.length - 1].edited_at - 1 : undefined
fetchMods({
source: filterOptions.source,
until
})
fetchMods(filterOptions.source, until)
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
@ -105,12 +99,9 @@ export const ModsPage = () => {
const handlePrev = useCallback(() => {
setIsFetching(true)
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
const since = mods.length > 0 ? mods[0].edited_at + 1 : undefined
fetchMods({
source: filterOptions.source,
since
})
fetchMods(filterOptions.source, undefined, since)
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
@ -126,54 +117,39 @@ 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 && !nsfwList.includes(mod.aTag))
return mods.filter((mod) => !mod.nsfw)
case NSFWFilter.Show_NSFW:
// If 'Show_NSFW' is selected, return all mods (no filtering)
return mods
case NSFWFilter.Only_NSFW:
// If 'Only_NSFW' is selected, filter to show only NSFW mods
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
return mods.filter((mod) => mod.nsfw)
}
}
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.user.authors.includes(mod.author) &&
!muteLists.user.replaceableEvents.includes(mod.aTag)
!muteLists.authors.includes(mod.author) &&
!muteLists.eventIds.includes(mod.id)
)
}
if (filterOptions.sort === SortBy.Latest) {
filtered.sort((a, b) => b.published_at - a.published_at)
filtered.sort((a, b) => b.edited_at - a.edited_at)
} else if (filterOptions.sort === SortBy.Oldest) {
filtered.sort((a, b) => a.published_at - b.published_at)
filtered.sort((a, b) => a.edited_at - b.edited_at)
}
return filtered
}, [
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
filterOptions.nsfw,
mods,
muteLists,
nsfwList
muteLists
])
return (
@ -190,32 +166,31 @@ export const ModsPage = () => {
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{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}
/>
)
})}
{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
})
)
)
}
/>
))}
</div>
</div>
<Pagination
page={page}
disabledNext={mods.length < MOD_FILTER_LIMIT}
disabledNext={mods.length < 20}
handlePrev={handlePrev}
handleNext={handleNext}
/>
@ -263,8 +238,6 @@ type FiltersProps = {
const Filters = React.memo(
({ filterOptions, setFilterOptions }: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
@ -308,30 +281,20 @@ const Filters = React.memo(
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
{Object.values(ModeratedFilter).map((item, index) => (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
@ -406,3 +369,42 @@ 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>
)
}
)

View File

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

View File

@ -1,586 +0,0 @@
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,11 +1,8 @@
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'
@ -13,13 +10,10 @@ import '../styles/profile.css'
import '../styles/settings.css'
import '../styles/styles.css'
import '../styles/write.css'
import { copyTextToClipboard } from '../utils'
import { MetadataController } from '../controllers'
import { useEffect, useState } from 'react'
import { useAppSelector } from '../hooks'
export const SettingsPage = () => {
const location = useLocation()
const userState = useAppSelector((state) => state.user)
return (
<div className='InnerBodyMain'>
@ -37,9 +31,7 @@ export const SettingsPage = () => {
<PreferencesSetting />
)}
{location.pathname === appRoutes.settingsAdmin && <AdminSetting />}
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
<ProfileSection />
</div>
</div>
</div>
@ -49,21 +41,8 @@ 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()
}
@ -72,7 +51,7 @@ const SettingTabs = () => {
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSplitMainSmallSideSec'>
<h3 className='IBMSMSMSSS_Text'>Settings (WIP)</h3>
<h3 className='IBMSMSMSSS_Text'>Settings</h3>
</div>
<div className='IBMSMSMSSS_Buttons'>
<Link
@ -135,75 +114,28 @@ const SettingTabs = () => {
</svg>
Preference
</Link>
{isAdmin && (
<Link
className={`btn btnMain btnMainAltText btnMainClear ${
location.pathname === appRoutes.settingsAdmin
? 'btnMainClearActive'
: ''
}`}
role='button'
to={appRoutes.settingsAdmin}
<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'
>
<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>
)}
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
</svg>
Admin
</Link>
</div>
{userState.auth &&
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 && (
{userState.isAuth && (
<button className='btn btnMain' type='button' onClick={handleSignOut}>
Sign out
</button>
@ -223,7 +155,7 @@ const ProfileSettings = () => {
<div
className='IBMSMSMSSS_Author_Top_Banner'
style={{
background: `url('assets/img/DEGMods%20Placeholder%20Img.png') center / cover no-repeat`
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`
}}
></div>
<a
@ -236,16 +168,16 @@ const ProfileSettings = () => {
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `url('/assets/img/DEG%20Mods%20Default%20PP.png') center / cover no-repeat`
background: `url('/assets/img/media-cache%20(4).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'>User name</p>
<p className='IBMSMSMSSS_Author_Top_Name'>Freakoverse</p>
<p className='IBMSMSMSSS_Author_Top_Handle'>
nip5handle@domain.com
freakoverse@degmods.com
</p>
</div>
</div>
@ -257,7 +189,7 @@ const ProfileSettings = () => {
id='SiteOwnerAddress-1'
className='IBMSMSMSSS_Author_Top_Address'
>
npub1address
npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
@ -288,7 +220,11 @@ const ProfileSettings = () => {
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
</svg>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapped'>
<a
className='IBMSMSMSSS_Author_Top_IconWrapped'
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
@ -297,15 +233,17 @@ const ProfileSettings = () => {
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
<path d='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>
</a>
</div>
</div>
<div className='IBMSMSMSSS_Author_Top_Details'>
<p className='IBMSMSMSSS_Author_Top_Bio'>
user bio, this is a long string of temporary text that would
be replaced with the user bio from their metada address
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
</p>
<div
id='OwnerFollowLogin-1'
@ -316,44 +254,21 @@ const ProfileSettings = () => {
</div>
</div>
<div className='IBMSMSMBSS_ProfileEdit'>
<InputField
label='Name'
placeholder=''
name='name'
value=''
onChange={() => {}}
/>
<InputField
label='Bio'
placeholder=''
name='bio'
type='textarea'
value=''
onChange={() => {}}
/>
<InputField label='Name' placeholder='' name='name' />
<InputField label='Bio' placeholder='' name='bio' type='textarea' />
<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'
@ -389,8 +304,6 @@ const RelaySettings = () => {
placeholder='wss://some-relay.com'
type='text'
name='relay'
value=''
onChange={() => {}}
/>
<button className='btn btnMain' type='button'>

View File

@ -11,31 +11,34 @@ import { ModDetails } from '../types'
import { toast } from 'react-toastify'
import { useState } from 'react'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { useAppSelector, useDidMount } from '../hooks'
import { useDidMount } from '../hooks'
export const SubmitModPage = () => {
const location = useLocation()
const { naddr } = useParams()
const { nevent } = 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 (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data
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 = {
'#a': [identifier],
authors: [pubkey],
kinds: [kind]
ids: [eventId]
}
if (kind) filter.kinds = [kind]
if (author) filter.authors = [author]
setIsFetching(true)
RelayController.getInstance()
.fetchEvent(filter, relays)
@ -76,9 +79,7 @@ export const SubmitModPage = () => {
)}
</div>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
<ProfileSection />
</div>
</div>
</div>

View File

@ -1,13 +1,10 @@
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'>
@ -15,46 +12,26 @@ export const WritePage = () => {
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Write a blog post (WIP)
</h2>
<h2 className='IBMSMTitleMainHeading'>Write a blog post</h2>
</div>
<div className='IBMSMSMBS_Write'>
<InputField
label='Title'
placeholder=''
name='title'
value=''
onChange={() => {}}
/>
<InputField
label='Body'
placeholder=''
name='body'
value=''
onChange={() => {}}
/>
<InputField label='Title' placeholder='' name='title' />
<InputField label='Body' placeholder='' name='body' />
<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'>
@ -63,9 +40,7 @@ export const WritePage = () => {
</div>
</div>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
<ProfileSection />
</div>
</div>
</div>

View File

@ -1,47 +1,32 @@
import { SearchPage } from 'pages/search'
import { AboutPage } from '../pages/about'
import { BlogsPage } from '../pages/blogs'
import { GamesPage } from '../pages/games'
import { HomePage } from '../pages/home'
import { ModPage } from '../pages/mod'
import { InnerModPage } from '../pages/innerMod'
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',
mod: '/mod/:naddr',
modsInner: '/mods-inner/:nevent',
about: '/about',
blog: '/blog',
submitMod: '/submit-mod',
editMod: '/edit-mod/:naddr',
editMod: '/edit-mod/:nevent',
write: '/write',
search: '/search',
settingsProfile: '/settings-profile',
settingsRelays: '/settings-relays',
settingsPreferences: '/settings-preferences',
settingsAdmin: '/settings-admin',
profile: '/profile/:nprofile'
settingsAdmin: '/settings-admin'
}
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 getModsInnerPageRoute = (eventId: string) =>
appRoutes.modsInner.replace(':nevent', eventId)
export const routes = [
{
@ -56,17 +41,13 @@ export const routes = [
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
path: appRoutes.modsInner,
element: <InnerModPage />
},
{
path: appRoutes.about,
@ -88,10 +69,6 @@ export const routes = [
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
@ -107,9 +84,5 @@ export const routes = [
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
}
]

View File

@ -1,25 +1,13 @@
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 {
auth: IUserAuth | null
isAuth: boolean
user: UserProfile
}
const initialState: IUserState = {
auth: null,
isAuth: false,
user: {}
}
@ -27,8 +15,8 @@ export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setAuth(state, action: PayloadAction<IUserAuth | null>) {
state = { ...state, auth: action.payload }
setIsAuth(state, action: PayloadAction<boolean>) {
state = { ...state, isAuth: action.payload }
return state
},
setUser(state, action: PayloadAction<UserProfile>) {
@ -38,6 +26,6 @@ export const userSlice = createSlice({
}
})
export const { setAuth, setUser } = userSlice.actions
export const { setIsAuth, 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,22 +22,16 @@
}
}
.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;
@ -45,27 +39,19 @@
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!important;
.swiper-button-next:after, .swiper-button-prev:after {
font-size: 18px;
}
@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: 45%;
width: 48%;
height: unset;
padding: 10px;
}
@ -102,17 +88,14 @@
@media (max-width: 992px) {
.swiper-slide.IBMSMSliderContainerWrapperSlider {
grid-template-columns: 1.15fr 0.85fr;
padding: 0 5px 25px 5px;
padding: 0 0 25px 0;
grid-gap: 15px;
}
}
@media (max-width: 768px) {
.swiper-slide.IBMSMSliderContainerWrapperSlider {
display: flex;
flex-direction: column;
grid-gap: 15px;
height: 500px;
grid-template-columns: 1fr;
}
}
@ -132,12 +115,7 @@
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;
}
@ -151,15 +129,12 @@
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;
}
@ -186,41 +161,23 @@
.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);
border: solid 1px rgba(255,255,255,0.05);
padding-top: 50%;
z-index: 1;
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;
}
box-shadow: 0 0 8px 0 rgba(0,0,0,0.25);
}
.IBMSMSCWSInfo {
@ -230,20 +187,14 @@
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 {
height: 100%;
/*margin: -25px 10px 0 10px;*/
}
}
@ -261,22 +212,19 @@
-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) {
@ -286,16 +234,7 @@
}
}
.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) {
@ -305,7 +244,7 @@
}
.swiper-pagination-bullet {
background: rgba(0, 0, 0, 0.5);
background: rgba(0,0,0,0.5);
opacity: 1;
width: 12px;
height: 12px;
@ -313,5 +252,6 @@
}
.swiper-pagination-bullet.swiper-pagination-bullet-active {
background: rgba(128, 0, 255, 0.5);
background: rgba(128,0,255,0.5);
}

View File

@ -5,9 +5,9 @@
grid-gap: 25px;
padding: 50px 15px;
border-radius: 15px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
color: rgba(255, 255, 255, 0.75);
background: rgba(255,255,255,0.05);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
color: rgba(255,255,255,0.75);
align-items: center;
justify-content: start;
position: relative;
@ -75,7 +75,7 @@
display: flex;
flex-direction: row;
grid-gap: 10px;
border-top: solid 1px rgba(255, 255, 255, 0.1);
border-top: solid 1px rgba(255,255,255,0.1);
padding: 10px 0 0 0;
}
@ -95,7 +95,7 @@
transition: ease 0.4s;
opacity: 1;
transform: scale(1.02);
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
}
.learnLinksLinkImg {
@ -103,7 +103,3 @@
max-width: 28px;
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW:hover {
border: unset;
transform: scale(1);
}

View File

@ -56,8 +56,6 @@
}
.IBMSMSMSSS_Author_Top_Icon {
min-width: 16px;
min-height: 16px;
}
.IBMSMSMSSS_Author_Top_Address {
@ -145,11 +143,10 @@
}
.IBMSMSMSSS_Author_Top_Name {
color: rgba(255,255,255,0.75);
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
white-space: nowrap;
text-overflow: ellipsis;
color: rgba(255,255,255,0.75);
}
.IBMSMSMSSS_Author_Top_PPWrapper {
@ -160,16 +157,6 @@
}
.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,7 +26,6 @@
}
.cardBlogMainInside {
transition: ease 0.4s;
position: absolute;
top: 0;
bottom: 0;
@ -37,12 +36,5 @@
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,24 +17,16 @@
transform: scale(1);
}
.cardGameMainWrapper {
position: relative;
padding-top: 150%;
}
.cardGameMain {
padding-top: 150%;
border-radius: 15px;
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;
background: rgba(255,255,255,0.05);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
}
.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;
@ -43,5 +35,5 @@
-webkit-line-clamp: 1;
font-size: 18px;
line-height: 1.5;
text-align: center;
}

View File

@ -9,18 +9,11 @@
background: linear-gradient(to top right, #262626, #292929, #262626);
}
.cMMPictureWrapper {
.cMMPicture {
position: relative;
width: 100%;
padding-top: 56.25%;
}
.cMMPicture {
position: absolute;
width: 100%;
height: 100%;
top: 0;
object-fit: cover; /* Ensures the image covers the container like a background image */
background: rgba(0, 0, 0, 0.1);
}
.cMMBody {
@ -30,7 +23,6 @@
flex-direction: column;
grid-gap: 15px;
flex-grow: 1;
justify-content: space-between;
}
.cMMFoot {
@ -106,26 +98,12 @@
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
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,13 +42,11 @@
.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;
display: flex;
flex-direction: column;
grid-gap: 5px;
/*border: solid 1px rgba(255,255,255,0.1);*/
}
.IBMSMSMBSSCL_CommentTopPPWrapper {
@ -61,20 +59,6 @@
.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;
@ -99,7 +83,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;
@ -134,20 +118,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;
@ -155,7 +139,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;
@ -163,42 +147,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 {
@ -212,15 +196,11 @@
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEUp.IBMSMSMBSSCL_CAEUpActive {
color: rgba(255, 70, 70, 0.85);
}
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEDown.IBMSMSMBSSCL_CAEDownActive {
color: rgba(255, 114, 54, 0.85);
color: rgba(255,70,70,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 {
@ -232,7 +212,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;
@ -253,12 +233,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 {
@ -297,16 +277,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) {
@ -316,37 +296,36 @@
}
}
.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);*/
@ -391,9 +370,9 @@ hover {
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;
}
@ -410,14 +389,14 @@ hover {
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 {
@ -460,7 +439,7 @@ hover {
}
.btnMain.CommentsToggleBtn.CommentsToggleActive {
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
font-weight: bold;
}
@ -471,11 +450,11 @@ hover {
}
.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 {
@ -489,7 +468,7 @@ hover {
}
.IBMSMSMBSSCL_CAElementLoad {
background: rgba(255, 255, 255, 0.5);
background: rgba(255,255,255,0.5);
width: 0%;
}
@ -497,3 +476,4 @@ hover {
padding: 5px 10px;
height: 100%;
}

View File

@ -4,7 +4,7 @@
grid-gap: 25px;
}
@media (max-width: 1200px) {
@media (max-width: 992px) {
.IBMSMSplitMain {
display: flex;
flex-direction: column;
@ -26,7 +26,6 @@
display: flex;
flex-direction: column;
grid-gap: 25px;
position: relative;
}
.IBMSMSplitMainBigSideSec {
@ -130,12 +129,3 @@
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,17 +33,8 @@
}
&:hover {
color: rgba(255, 255, 255, 0.75);
color: rgb(255, 255, 255);
}
a {
color: white;
&:hover {
color: white;
text-decoration: underline;
}
}
}
}
@ -147,7 +138,7 @@
width: auto;
padding: 10px 15px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.5);
color: rgba(255, 255, 255, 0.25);
text-decoration: unset;
font-weight: bold;
transform: scale(1);
@ -158,7 +149,7 @@
&:hover {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.85);
color: rgba(255, 255, 255, 0.75);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
text-decoration: unset;
@ -182,7 +173,12 @@
bottom: 0;
right: 0;
left: 0;
background: #323232;
background: linear-gradient(
to top right,
#262626,
#292929,
#262626
);
z-index: -1;
border-radius: 10px;
}
@ -203,7 +199,7 @@
height: auto;
width: auto;
border-radius: 10px;
color: rgba(255, 255, 255, 0.5);
color: rgba(255, 255, 255, 0.25);
text-decoration: unset;
font-weight: bold;
transform: scale(1);
@ -245,80 +241,32 @@
}
.NavMainBottomInside {
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 {
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;
}
&.NMBILActive {
color: rgba(255, 255, 255, 0.65);
}
}
@media (max-width: 768px) {
width: 100%;
display: flex;
justify-content: start;
}
}
flex-direction: row;
grid-gap: 10px;
justify-content: center;
align-items: center;
padding: 10px;
white-space: nowrap;
overflow-x: auto;
.NavMainBottomInsideOther {
display: flex;
flex-direction: row;
grid-gap: 10px;
height: 100%;
justify-content: end;
.NavMainBottomInsideLink {
transition: ease 0.4s;
text-decoration: unset;
color: rgba(255, 255, 255, 0.25);
font-weight: bold;
padding: 5px 15px;
.NavMainBottomInsideOtherLink {
&:hover {
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;
}
color: rgba(255, 255, 255, 0.75);
text-decoration: unset;
}
.NavMainBottomInsideOtherRight {
display: flex;
justify-content: end;
&.NMBILActive {
color: rgba(255, 255, 255, 0.65);
}
}
}
}

View File

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

View File

@ -5,7 +5,7 @@
bottom: 0;
left: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
background: rgba(0,0,0,0.5);
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
@ -19,7 +19,7 @@
width: 100%;
max-width: 1000px;
border-radius: 15px;
box-shadow: 0 0 16px 0 rgba(0, 0, 0, 0.5);
box-shadow: 0 0 16px 0 rgba(0,0,0,0.5);
background: #232323;
}
@ -42,7 +42,7 @@
justify-content: end;
align-items: center;
padding: 15px 25px 10px 25px;
border-bottom: solid 1px rgba(255, 255, 255, 0.05);
border-bottom: solid 1px rgba(255,255,255,0.05);
}
.popUpMainCardBottom {
@ -53,25 +53,12 @@
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 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: inherit;
display: inline;
cursor: pointer;
color: rgba(255, 255, 255, 0.5);
}
.popUpMainCardTopClose {
@ -82,23 +69,25 @@
flex-direction: column;
justify-content: center;
align-items: end;
color: rgba(255, 255, 255, 0.25);
color: rgba(255,255,255,0.25);
padding: 10px;
border-radius: 10px;
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);
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 {
width: 100%;
color: rgba(255, 255, 255, 0.75);
color: rgba(255,255,255,0.75);
}
.popUpMainCardTopClose:hover::before {
@ -115,7 +104,7 @@
bottom: 0;
right: 0;
left: 0;
background: #323232;
background: linear-gradient(to top right, #262626, #292929, #262626);
/*z-index: -1;*/
border-radius: 10px;
}
@ -133,7 +122,7 @@
grid-gap: 25px;
display: flex;
flex-direction: column;
border: solid 1px rgba(255, 255, 255, 0.1);
border: solid 1px rgba(255,255,255,0.1);
border-radius: 10px;
}
@ -164,13 +153,13 @@
.btnMain.pUMCB_ZapsInsideElementBtn {
width: 100%;
background: rgba(255, 255, 255, 0.05);
background: rgba(255,255,255,0.05);
}
.btnMain.pUMCB_ZapsInsideElementBtn:hover {
transition: ease 0.4s;
color: yellow;
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
}
.pUMCB_ZapsInsideAmount {
@ -196,7 +185,7 @@
justify-content: center;
align-items: center;
grid-gap: 10px;
color: rgba(255, 255, 255, 0.5);
color: rgba(255,255,255,0.5);
border-radius: 10px;
cursor: pointer;
font-weight: bold;
@ -206,7 +195,7 @@
.btnMain.pUMCB_ZapsInsideAmountOptionsBtn:hover {
transition: ease 0.4s;
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
color: yellow;
}
@ -217,12 +206,12 @@
grid-gap: 25px;
justify-content: center;
align-items: center;
color: rgba(255, 255, 255, 0.5);
color: rgba(255,255,255,0.5);
}
.dividerPopupLine {
height: 1px;
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
flex-grow: 1;
}
@ -259,7 +248,7 @@
.popUpMainGalleryInsideMid {
/*flex-grow: 1;*/
background: rgba(0, 0, 0, 0.5);
background: rgba(0,0,0,0.5);
/*height: 100%;*/
padding-top: 100% * (16 / 9);
width: 100%;
@ -306,7 +295,7 @@
.ZapSplitUserBox {
border-radius: 10px;
border: solid 1px rgba(255, 255, 255, 0.1);
border: solid 1px rgba(255,255,255,0.1);
padding: 10px;
display: flex;
flex-direction: column;
@ -321,7 +310,7 @@
width: 100%;
border-radius: 10px;
text-decoration: unset;
color: rgba(255, 255, 255, 0.65);
color: rgba(255,255,255,0.65);
}
.ZapSplitUserBoxUserPic {
@ -346,7 +335,7 @@
}
.ZapSplitUserBoxText {
color: rgba(255, 255, 255, 0.5);
color: rgba(255,255,255,0.5);
}
.ZapSplitUserBoxRange {
@ -357,17 +346,17 @@
.ZapSplitUserBoxRangeText {
white-space: nowrap;
color: rgba(255, 255, 255, 0.5);
color: rgba(255,255,255,0.5);
}
.keyGenerationTable {
width: 100%;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
background: rgba(255,255,255,0.05);
display: flex;
flex-direction: column;
border: solid 1px rgba(255, 255, 255, 0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
border: solid 1px rgba(255,255,255,0.1);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
overflow: hidden;
}
@ -389,7 +378,7 @@
}
.keyGenerationTableRowColText {
color: rgba(255, 255, 255, 0.75);
color: rgba(255,255,255,0.75);
overflow: hidden;
text-overflow: ellipsis;
}
@ -399,41 +388,6 @@
}
.keyGenerationTableRowCol.keyGenerationTableRowColStart {
background: rgba(255, 255, 255, 0.1);
background: rgba(255,255,255,0.1);
}
.popUpMainCardBottom.popUpMainCardBottomAlt {
padding: 0;
}
.popUpMainCardFooter {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 15px 10px;
}
.pUMCB_ZapsInsideBtns {
display: grid;
grid-template-columns: 0.25fr 1.75fr;
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,10 +40,6 @@
overflow: hidden;
}
.IBMSMSMBSSPostBody > div {
width: 100%;
}
.IBMSMSMBSSPostTitleHeading {
width: 100%;
}
@ -52,14 +48,6 @@
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;

View File

@ -1,121 +0,0 @@
.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,11 +3,6 @@
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,18 +1,3 @@
.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%;
@ -46,30 +31,6 @@ 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;
@ -270,33 +231,6 @@ 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(
@ -315,7 +249,6 @@ h6 {
justify-content: start;
align-items: center;
grid-gap: 10px;
cursor: pointer;
}
.dropdownMainMenuItem:hover {
@ -345,38 +278,6 @@ 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);
@ -391,12 +292,6 @@ h6 {
flex-direction: row;
grid-gap: 10px;
position: relative;
overflow: hidden;
border-radius: 10px;
}
.inputWrapperMain.inputWrapperMainAlt {
overflow: unset;
}
.inputWrapperMainWrapper {
@ -566,7 +461,7 @@ a {
}
a:hover {
color: rgba(255, 115, 255, 0.85);
color: rgba(255, 115, 255, 0.75);
text-decoration: underline;
}
@ -655,28 +550,3 @@ 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

@ -12,22 +12,21 @@
transition: ease 0.4s;
padding: 5px 15px;
border-radius: 10px;
background: rgba(255, 255, 255, 0);
color: rgba(255, 255, 255, 0.25);
background: rgba(255,255,255,0);
color: rgba(255,255,255,0.25);
text-decoration: unset;
text-align: center;
cursor: pointer;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0);
border: solid 1px rgba(255, 255, 255, 0.05);
box-shadow: 0 0 8px 0 rgba(0,0,0,0);
border: solid 1px rgba(255,255,255,0.05);
}
.IBMSMSMBSSTagsTag:hover {
transition: ease 0.4s;
transform: scale(1.02);
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;
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);
}
.IBMSMSMBSSTagsTag:active {
@ -35,12 +34,3 @@
transform: scale(0.98);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW {
background: rgba(255, 255, 255, 0.1);
color: rgb(225, 68, 68);
font-weight: bold;
border: unset;
font-size: 14px;
cursor: default;
box-shadow: unset;
}

View File

@ -1,104 +0,0 @@
/* 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,4 +1,2 @@
export * from './mod'
export * from './nostr'
export * from './user'
export * from './zap'

View File

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

View File

@ -1,9 +0,0 @@
export interface SignedEvent {
kind: number
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
}

View File

@ -1,40 +0,0 @@
import { kinds } from 'nostr-tools'
import { SignedEvent } from '.'
export interface LnurlResponse {
callback: string
metadata: string
minSendable: number
maxSendable: number
commentAllowed: number
tag: string
nostrPubkey: string
allowsNostr: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isLnurlResponse = (obj: any): obj is LnurlResponse =>
'callback' in obj &&
'metadata' in obj &&
'minSendable' in obj &&
'maxSendable' in obj &&
'commentAllowed' in obj &&
'tag' in obj &&
'nostrPubkey' in obj &&
'allowsNostr' in obj
export interface ZapRequest {
kind: typeof kinds.ZapRequest
content: string
tags: string[][]
pubkey: string
created_at: number
}
export interface PaymentRequest extends SignedEvent {
pr: string
}
export interface ZapReceipt extends SignedEvent {
kind: typeof kinds.Zap
}

View File

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

View File

@ -4,8 +4,7 @@ import { ModFormState, ModDetails } from '../types'
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'
import { T_TAG_VALUE } from '../constants'
/**
* Extracts and normalizes mod data from an event.
@ -134,13 +133,6 @@ export const initializeFormState = (
]
})
interface FetchModsOptions {
source?: string
until?: number
since?: number
limit?: number
}
/**
* Fetches a list of mods based on the provided source.
*
@ -151,16 +143,15 @@ interface FetchModsOptions {
* @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,
until,
since,
limit
}: FetchModsOptions): Promise<ModDetails[]> => {
export const fetchMods = async (
source: string,
until?: number,
since?: number
): Promise<ModDetails[]> => {
// Define the filter criteria for fetching mods
const filter: Filter = {
kinds: [kinds.ClassifiedListing], // Specify the kind of events to fetch
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
limit: 20, // 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
@ -175,6 +166,8 @@ 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
@ -191,31 +184,3 @@ 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,16 +1,4 @@
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'
import { nip19, Event } from 'nostr-tools'
/**
* Get the current time in seconds since the Unix epoch (January 1, 1970).
@ -96,126 +84,3 @@ 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,32 +90,3 @@ 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

@ -67,71 +67,3 @@ export const copyTextToClipboard = async (text: string): Promise<boolean> => {
return false // Failed to copy
}
}
/**
* Formats a number with commas as thousands separators.
*
* @param value - The number to be formatted.
* @returns A string representing the formatted number.
*/
export const formatNumber = (value: number): string => {
// Use `Math.round` to ensure the number is rounded to the nearest integer.
// `Intl.NumberFormat` creates a number format object for formatting numbers.
// The `format` method applies the format to the rounded number.
return new Intl.NumberFormat().format(Math.round(value))
}
/**
* Converts a formatted number string back to a numeric value.
*
* This function removes any commas used as thousand separators and parses the resulting string
* to a floating-point number. If the input is not a valid number, it returns 0.
*
* @param value - The formatted number string (e.g., "1,234,567.89").
* @returns The numeric value represented by the string. Returns 0 if parsing fails.
*/
export const unformatNumber = (value: string): number => {
// Remove commas from the input string. The regular expression `/\,/g` matches all commas.
// Replace them with an empty string to get a plain numeric string.
// `parseFloat` converts the resulting string to a floating-point 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
}

View File

@ -1,38 +0,0 @@
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,9 +3,6 @@
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,11 +23,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"plugins": [{ "name": "ts-css-modules-vite-plugin" }],
"paths": {
"*": ["./src/*"]
}
"plugins": [{ "name": "ts-css-modules-vite-plugin" }]
},
"include": ["src"]
}

View File

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